mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
refactor(infra): directory structure (#4615)
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
export interface FetchOptions {
|
||||
fetch?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
|
||||
normalizeURL?(url: string): string;
|
||||
|
||||
/**
|
||||
* Virtualize a url
|
||||
* @param url URL to be rewrite
|
||||
* @param direction Direction of this rewrite.
|
||||
* 'in' means the url is from the outside world and should be virtualized.
|
||||
* 'out' means the url is from the inside world and should be de-virtualized to fetch the real target.
|
||||
*/
|
||||
rewriteURL?(url: string, direction: 'in' | 'out'): string;
|
||||
|
||||
replaceRequest?(request: Request): Request | PromiseLike<Request>;
|
||||
|
||||
replaceResponse?(response: Response): Response | PromiseLike<Response>;
|
||||
|
||||
canConnect?(url: string): boolean | PromiseLike<boolean>;
|
||||
}
|
||||
|
||||
export function createFetch(options: FetchOptions) {
|
||||
const {
|
||||
fetch: _fetch = fetch,
|
||||
signal,
|
||||
rewriteURL,
|
||||
replaceRequest,
|
||||
replaceResponse,
|
||||
canConnect,
|
||||
normalizeURL,
|
||||
} = options;
|
||||
|
||||
return async function fetch(input: RequestInfo, init?: RequestInit) {
|
||||
let request = new Request(input, {
|
||||
...init,
|
||||
signal: getMergedSignal(init?.signal, signal) || null,
|
||||
});
|
||||
|
||||
if (normalizeURL) request = new Request(normalizeURL(request.url), request);
|
||||
if (canConnect && !(await canConnect(request.url)))
|
||||
throw new TypeError('Failed to fetch');
|
||||
if (rewriteURL)
|
||||
request = new Request(rewriteURL(request.url, 'out'), request);
|
||||
if (replaceRequest) request = await replaceRequest(request);
|
||||
|
||||
let response = await _fetch(request);
|
||||
|
||||
if (rewriteURL) {
|
||||
const { url, redirected, type } = response;
|
||||
// Note: Response constructor does not allow us to set the url of a response.
|
||||
// we have to define the own property on it. This is not a good simulation.
|
||||
// To prevent get the original url by Response.prototype.[[get url]].call(response)
|
||||
// we copy a response and set it's url to empty.
|
||||
response = new Response(response.body, response);
|
||||
Object.defineProperties(response, {
|
||||
url: { value: url, configurable: true },
|
||||
redirected: { value: redirected, configurable: true },
|
||||
type: { value: type, configurable: true },
|
||||
});
|
||||
Object.defineProperty(response, 'url', {
|
||||
configurable: true,
|
||||
value: rewriteURL(url, 'in'),
|
||||
});
|
||||
}
|
||||
if (replaceResponse) response = await replaceResponse(response);
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
function getMergedSignal(
|
||||
signal: AbortSignal | undefined | null,
|
||||
signal2: AbortSignal | undefined | null
|
||||
) {
|
||||
if (!signal) return signal2;
|
||||
if (!signal2) return signal;
|
||||
|
||||
const abortController = new AbortController();
|
||||
signal.addEventListener('abort', () => abortController.abort(), {
|
||||
once: true,
|
||||
});
|
||||
signal2.addEventListener('abort', () => abortController.abort(), {
|
||||
once: true,
|
||||
});
|
||||
return abortController.signal;
|
||||
}
|
||||
110
packages/frontend/core/src/bootstrap/plugins/endowments/timer.ts
Normal file
110
packages/frontend/core/src/bootstrap/plugins/endowments/timer.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
type Handler = (...args: any[]) => void;
|
||||
|
||||
export interface Timers {
|
||||
setTimeout: (handler: Handler, timeout?: number, ...args: any[]) => number;
|
||||
clearTimeout: (handle: number) => void;
|
||||
setInterval: (handler: Handler, timeout?: number, ...args: any[]) => number;
|
||||
clearInterval: (handle: number) => void;
|
||||
requestAnimationFrame: (callback: Handler) => number;
|
||||
cancelAnimationFrame: (handle: number) => void;
|
||||
requestIdleCallback?: typeof window.requestIdleCallback | undefined;
|
||||
cancelIdleCallback?: typeof window.cancelIdleCallback | undefined;
|
||||
queueMicrotask: typeof window.queueMicrotask;
|
||||
}
|
||||
|
||||
export function createTimers(
|
||||
abortSignal: AbortSignal,
|
||||
originalTimes: Timers = {
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
requestIdleCallback:
|
||||
typeof requestIdleCallback === 'function'
|
||||
? requestIdleCallback
|
||||
: undefined,
|
||||
cancelIdleCallback:
|
||||
typeof cancelIdleCallback === 'function' ? cancelIdleCallback : undefined,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
queueMicrotask,
|
||||
}
|
||||
): Timers {
|
||||
const {
|
||||
requestAnimationFrame: _requestAnimationFrame,
|
||||
cancelAnimationFrame: _cancelAnimationFrame,
|
||||
setInterval: _setInterval,
|
||||
clearInterval: _clearInterval,
|
||||
setTimeout: _setTimeout,
|
||||
clearTimeout: _clearTimeout,
|
||||
cancelIdleCallback: _cancelIdleCallback,
|
||||
requestIdleCallback: _requestIdleCallback,
|
||||
queueMicrotask: _queueMicrotask,
|
||||
} = originalTimes;
|
||||
|
||||
const interval_timer_id: number[] = [];
|
||||
const idle_id: number[] = [];
|
||||
const raf_id: number[] = [];
|
||||
|
||||
abortSignal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
raf_id.forEach(_cancelAnimationFrame);
|
||||
interval_timer_id.forEach(_clearInterval);
|
||||
_cancelIdleCallback && idle_id.forEach(_cancelIdleCallback);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
return {
|
||||
// id is a positive number, it never repeats.
|
||||
requestAnimationFrame(callback) {
|
||||
raf_id[raf_id.length] = _requestAnimationFrame(callback);
|
||||
return raf_id.length;
|
||||
},
|
||||
cancelAnimationFrame(handle) {
|
||||
const id = raf_id[handle - 1];
|
||||
if (!id) return;
|
||||
_cancelAnimationFrame(id);
|
||||
},
|
||||
setInterval(handler, timeout) {
|
||||
interval_timer_id[interval_timer_id.length] = (_setInterval as any)(
|
||||
handler,
|
||||
timeout
|
||||
);
|
||||
return interval_timer_id.length;
|
||||
},
|
||||
clearInterval(id) {
|
||||
if (!id) return;
|
||||
const handle = interval_timer_id[id - 1];
|
||||
if (!handle) return;
|
||||
_clearInterval(handle);
|
||||
},
|
||||
setTimeout(handler, timeout) {
|
||||
idle_id[idle_id.length] = (_setTimeout as any)(handler, timeout);
|
||||
return idle_id.length;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
if (!id) return;
|
||||
const handle = idle_id[id - 1];
|
||||
if (!handle) return;
|
||||
_clearTimeout(handle);
|
||||
},
|
||||
requestIdleCallback: _requestIdleCallback
|
||||
? function requestIdleCallback(callback, options) {
|
||||
idle_id[idle_id.length] = _requestIdleCallback(callback, options);
|
||||
return idle_id.length;
|
||||
}
|
||||
: undefined,
|
||||
cancelIdleCallback: _cancelIdleCallback
|
||||
? function cancelIdleCallback(handle) {
|
||||
const id = idle_id[handle - 1];
|
||||
if (!id) return;
|
||||
_cancelIdleCallback(id);
|
||||
}
|
||||
: undefined,
|
||||
queueMicrotask(callback) {
|
||||
_queueMicrotask(() => abortSignal.aborted || callback());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
type ExportsPromiseOrExports =
|
||||
| Promise<{ [key: string]: any }>
|
||||
| { [key: string]: any };
|
||||
|
||||
export async function setupImportsMap(
|
||||
map: Map<string, Map<string, any>>,
|
||||
imports: Record<string, ExportsPromiseOrExports>
|
||||
) {
|
||||
for (const [key, value] of Object.entries(imports)) {
|
||||
let module: { [key: string]: any };
|
||||
if (value instanceof Promise) {
|
||||
module = await value;
|
||||
} else {
|
||||
module = value;
|
||||
}
|
||||
const moduleMap = new Map();
|
||||
map.set(key, moduleMap);
|
||||
for (const [exportName, exportValue] of Object.entries(module)) {
|
||||
moduleMap.set(exportName, exportValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
584
packages/frontend/core/src/bootstrap/plugins/setup.ts
Normal file
584
packages/frontend/core/src/bootstrap/plugins/setup.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type {
|
||||
CallbackMap,
|
||||
ExpectedLayout,
|
||||
LayoutNode,
|
||||
PluginContext,
|
||||
} from '@affine/sdk/entry';
|
||||
import { AffineFormatBarWidget } from '@blocksuite/blocks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
addCleanup,
|
||||
pluginEditorAtom,
|
||||
pluginHeaderItemAtom,
|
||||
pluginSettingAtom,
|
||||
pluginWindowAtom,
|
||||
} from '@toeverything/infra/__internal__/plugin';
|
||||
import {
|
||||
contentLayoutAtom,
|
||||
currentPageIdAtom,
|
||||
currentWorkspaceAtom,
|
||||
} from '@toeverything/infra/atom';
|
||||
import { atom } from 'jotai';
|
||||
import { Provider } from 'jotai/react';
|
||||
import type { createStore } from 'jotai/vanilla';
|
||||
import { createElement, type PropsWithChildren } from 'react';
|
||||
|
||||
import { createFetch } from './endowments/fercher';
|
||||
import { createTimers } from './endowments/timer';
|
||||
import { setupImportsMap } from './setup-imports-map';
|
||||
|
||||
// DO NOT REMOVE INVISIBLE CHARACTERS
|
||||
const dynamicImportKey = '$h_import';
|
||||
|
||||
const permissionLogger = new DebugLogger('plugins:permission');
|
||||
const importLogger = new DebugLogger('plugins:import');
|
||||
const entryLogger = new DebugLogger('plugins:entry');
|
||||
|
||||
const pushLayoutAtom = atom<
|
||||
null,
|
||||
// fixme: check plugin name here
|
||||
[
|
||||
pluginName: string,
|
||||
create: (root: HTMLElement) => () => void,
|
||||
options:
|
||||
| {
|
||||
maxWidth: (number | undefined)[];
|
||||
}
|
||||
| undefined,
|
||||
],
|
||||
void
|
||||
>(null, (_, set, pluginName, callback, options) => {
|
||||
set(pluginWindowAtom, items => ({
|
||||
...items,
|
||||
[pluginName]: callback,
|
||||
}));
|
||||
set(contentLayoutAtom, layout => {
|
||||
if (layout === 'editor') {
|
||||
return {
|
||||
direction: 'horizontal',
|
||||
first: 'editor',
|
||||
second: pluginName,
|
||||
splitPercentage: 70,
|
||||
maxWidth: options?.maxWidth,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
direction: 'horizontal',
|
||||
first: 'editor',
|
||||
splitPercentage: 70,
|
||||
second: {
|
||||
direction: 'horizontal',
|
||||
first: pluginName,
|
||||
second: layout.second,
|
||||
splitPercentage: 50,
|
||||
},
|
||||
} satisfies ExpectedLayout;
|
||||
}
|
||||
});
|
||||
addCleanup(pluginName, () => {
|
||||
set(deleteLayoutAtom, pluginName);
|
||||
});
|
||||
});
|
||||
|
||||
const deleteLayoutAtom = atom<null, [string], void>(null, (_, set, id) => {
|
||||
set(pluginWindowAtom, items => {
|
||||
const newItems = { ...items };
|
||||
delete newItems[id];
|
||||
return newItems;
|
||||
});
|
||||
const removeLayout = (layout: LayoutNode): LayoutNode | string => {
|
||||
if (typeof layout === 'string') {
|
||||
return layout;
|
||||
}
|
||||
if (layout.first === id) {
|
||||
return layout.second;
|
||||
} else if (layout.second === id) {
|
||||
return layout.first;
|
||||
} else {
|
||||
return {
|
||||
...layout,
|
||||
second: removeLayout(layout.second),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
set(contentLayoutAtom, layout => {
|
||||
if (layout === 'editor') {
|
||||
return 'editor';
|
||||
} else {
|
||||
return removeLayout(layout) as ExpectedLayout;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const setupWeakMap = new WeakMap<
|
||||
ReturnType<typeof createStore>,
|
||||
ReturnType<typeof createSetupImpl>
|
||||
>();
|
||||
|
||||
export function createSetup(rootStore: ReturnType<typeof createStore>) {
|
||||
if (setupWeakMap.has(rootStore)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return setupWeakMap.get(rootStore)!;
|
||||
}
|
||||
const setup = createSetupImpl(rootStore);
|
||||
setupWeakMap.set(rootStore, setup);
|
||||
return setup;
|
||||
}
|
||||
|
||||
function createSetupImpl(rootStore: ReturnType<typeof createStore>) {
|
||||
// clean up plugin windows when switching to other pages
|
||||
rootStore.sub(currentPageIdAtom, () => {
|
||||
rootStore.set(contentLayoutAtom, 'editor');
|
||||
});
|
||||
|
||||
// module -> importName -> updater[]
|
||||
const _rootImportsMap = new Map<string, Map<string, any>>();
|
||||
const rootImportsMapSetupPromise = setupImportsMap(_rootImportsMap, {
|
||||
react: import('react'),
|
||||
'react/jsx-runtime': import('react/jsx-runtime'),
|
||||
'react-dom': import('react-dom'),
|
||||
'react-dom/client': import('react-dom/client'),
|
||||
jotai: import('jotai'),
|
||||
'jotai/utils': import('jotai/utils'),
|
||||
swr: import('swr'),
|
||||
'@affine/component': import('@affine/component'),
|
||||
'@blocksuite/icons': import('@blocksuite/icons'),
|
||||
'@blocksuite/blocks': import('@blocksuite/blocks'),
|
||||
'@affine/sdk/entry': {
|
||||
rootStore,
|
||||
currentWorkspaceAtom: currentWorkspaceAtom,
|
||||
currentPageIdAtom: currentPageIdAtom,
|
||||
pushLayoutAtom: pushLayoutAtom,
|
||||
deleteLayoutAtom: deleteLayoutAtom,
|
||||
},
|
||||
'@blocksuite/global/utils': import('@blocksuite/global/utils'),
|
||||
'@toeverything/infra/atom': import('@toeverything/infra/atom'),
|
||||
'@toeverything/components/button': import(
|
||||
'@toeverything/components/button'
|
||||
),
|
||||
'@toeverything/components/tooltip': import(
|
||||
'@toeverything/components/tooltip'
|
||||
),
|
||||
});
|
||||
|
||||
// pluginName -> module -> importName -> updater[]
|
||||
const _pluginNestedImportsMap = new Map<
|
||||
string,
|
||||
Map<string, Map<string, any>>
|
||||
>();
|
||||
|
||||
const pluginImportsFunctionMap = new Map<
|
||||
string,
|
||||
(newUpdaters: [string, [string, ((val: any) => void)[]][]][]) => void
|
||||
>();
|
||||
const createImports = (pluginName: string) => {
|
||||
if (pluginImportsFunctionMap.has(pluginName)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return pluginImportsFunctionMap.get(pluginName)!;
|
||||
}
|
||||
const imports = (
|
||||
newUpdaters: [string, [string, ((val: any) => void)[]][]][]
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const currentImportMap = _pluginNestedImportsMap.get(pluginName)!;
|
||||
importLogger.debug('currentImportMap', pluginName, currentImportMap);
|
||||
|
||||
for (const [module, moduleUpdaters] of newUpdaters) {
|
||||
importLogger.debug('imports module', module, moduleUpdaters);
|
||||
let moduleImports = _rootImportsMap.get(module);
|
||||
if (!moduleImports) {
|
||||
moduleImports = currentImportMap.get(module);
|
||||
}
|
||||
if (moduleImports) {
|
||||
for (const [importName, importUpdaters] of moduleUpdaters) {
|
||||
const updateImport = (value: any) => {
|
||||
for (const importUpdater of importUpdaters) {
|
||||
importUpdater(value);
|
||||
}
|
||||
};
|
||||
if (moduleImports.has(importName)) {
|
||||
const val = moduleImports.get(importName);
|
||||
updateImport(val);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
'cannot find module in plugin import map',
|
||||
module,
|
||||
currentImportMap,
|
||||
_pluginNestedImportsMap
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
pluginImportsFunctionMap.set(pluginName, imports);
|
||||
return imports;
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
const pluginFetch = createFetch({});
|
||||
const timer = createTimers(abortController.signal);
|
||||
|
||||
const sharedGlobalThis = Object.assign(Object.create(null), timer, {
|
||||
Object: globalThis.Object,
|
||||
fetch: pluginFetch,
|
||||
ReadableStream: globalThis.ReadableStream,
|
||||
Symbol: globalThis.Symbol,
|
||||
Error: globalThis.Error,
|
||||
TypeError: globalThis.TypeError,
|
||||
RangeError: globalThis.RangeError,
|
||||
console: globalThis.console,
|
||||
crypto: globalThis.crypto,
|
||||
});
|
||||
|
||||
const dynamicImportMap = new Map<
|
||||
string,
|
||||
(moduleName: string) => Promise<any>
|
||||
>();
|
||||
|
||||
const createOrGetDynamicImport = (baseUrl: string, pluginName: string) => {
|
||||
if (dynamicImportMap.has(pluginName)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return dynamicImportMap.get(pluginName)!;
|
||||
}
|
||||
const dynamicImport = async (moduleName: string): Promise<any> => {
|
||||
const codeUrl = `${baseUrl}/${moduleName}`;
|
||||
const analysisUrl = `${baseUrl}/${moduleName}.json`;
|
||||
const response = await fetch(codeUrl);
|
||||
const analysisResponse = await fetch(analysisUrl);
|
||||
const analysis = await analysisResponse.json();
|
||||
const exports = analysis.exports as string[];
|
||||
const code = await response.text();
|
||||
const moduleCompartment = new Compartment(
|
||||
createOrGetGlobalThis(
|
||||
pluginName,
|
||||
// use singleton here to avoid infinite loop
|
||||
createOrGetDynamicImport(pluginName, baseUrl)
|
||||
)
|
||||
);
|
||||
const entryPoint = moduleCompartment.evaluate(code, {
|
||||
__evadeHtmlCommentTest__: true,
|
||||
});
|
||||
const moduleExports = {} as Record<string, any>;
|
||||
const setVarProxy = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, p: string): any {
|
||||
return (newValue: any) => {
|
||||
moduleExports[p] = newValue;
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
entryPoint({
|
||||
imports: createImports(pluginName),
|
||||
liveVar: setVarProxy,
|
||||
onceVar: setVarProxy,
|
||||
});
|
||||
importLogger.debug('import', moduleName, exports, moduleExports);
|
||||
return moduleExports;
|
||||
};
|
||||
dynamicImportMap.set(pluginName, dynamicImport);
|
||||
return dynamicImport;
|
||||
};
|
||||
|
||||
const globalThisMap = new Map<string, any>();
|
||||
|
||||
const createOrGetGlobalThis = (
|
||||
pluginName: string,
|
||||
dynamicImport: (moduleName: string) => Promise<any>
|
||||
) => {
|
||||
if (globalThisMap.has(pluginName)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return globalThisMap.get(pluginName)!;
|
||||
}
|
||||
const pluginGlobalThis = Object.assign(
|
||||
Object.create(null),
|
||||
sharedGlobalThis,
|
||||
{
|
||||
// fixme: vite build output bundle will have this, we should remove it
|
||||
process: Object.freeze({
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
}),
|
||||
// dynamic import function
|
||||
[dynamicImportKey]: dynamicImport,
|
||||
// UNSAFE: React will read `window` and `document`
|
||||
window: new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, key) {
|
||||
permissionLogger.debug(`${pluginName} is accessing window`, key);
|
||||
if (sharedGlobalThis[key]) return sharedGlobalThis[key];
|
||||
const result = Reflect.get(window, key);
|
||||
if (typeof result === 'function') {
|
||||
if (result === ShadowRoot) {
|
||||
return result;
|
||||
}
|
||||
return function (...args: any[]) {
|
||||
permissionLogger.debug(
|
||||
`${pluginName} is calling window`,
|
||||
key,
|
||||
args
|
||||
);
|
||||
return result.apply(window, args);
|
||||
};
|
||||
}
|
||||
permissionLogger.debug('window', key, result);
|
||||
return result;
|
||||
},
|
||||
}
|
||||
),
|
||||
document: new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, key) {
|
||||
permissionLogger.debug(
|
||||
`${pluginName} is accessing document`,
|
||||
key
|
||||
);
|
||||
if (sharedGlobalThis[key]) return sharedGlobalThis[key];
|
||||
const result = Reflect.get(document, key);
|
||||
if (typeof result === 'function') {
|
||||
return function (...args: any[]) {
|
||||
permissionLogger.debug(
|
||||
`${pluginName} is calling window`,
|
||||
key,
|
||||
args
|
||||
);
|
||||
return result.apply(document, args);
|
||||
};
|
||||
}
|
||||
permissionLogger.debug('document', key, result);
|
||||
return result;
|
||||
},
|
||||
}
|
||||
),
|
||||
navigator: globalThis.navigator,
|
||||
|
||||
MouseEvent: globalThis.MouseEvent,
|
||||
KeyboardEvent: globalThis.KeyboardEvent,
|
||||
CustomEvent: globalThis.CustomEvent,
|
||||
|
||||
// copilot uses these
|
||||
Date: globalThis.Date,
|
||||
Math: globalThis.Math,
|
||||
URL: globalThis.URL,
|
||||
URLSearchParams: globalThis.URLSearchParams,
|
||||
Headers: globalThis.Headers,
|
||||
TextEncoder: globalThis.TextEncoder,
|
||||
TextDecoder: globalThis.TextDecoder,
|
||||
Request: globalThis.Request,
|
||||
|
||||
// image-preview uses these
|
||||
Blob: globalThis.Blob,
|
||||
ClipboardItem: globalThis.ClipboardItem,
|
||||
|
||||
// vue uses these
|
||||
Element: globalThis.Element,
|
||||
SVGElement: globalThis.SVGElement,
|
||||
|
||||
// fixme: use our own db api
|
||||
indexedDB: globalThis.indexedDB,
|
||||
IDBRequest: globalThis.IDBRequest,
|
||||
IDBDatabase: globalThis.IDBDatabase,
|
||||
IDBCursorWithValue: globalThis.IDBCursorWithValue,
|
||||
IDBFactory: globalThis.IDBFactory,
|
||||
IDBKeyRange: globalThis.IDBKeyRange,
|
||||
IDBOpenDBRequest: globalThis.IDBOpenDBRequest,
|
||||
IDBTransaction: globalThis.IDBTransaction,
|
||||
IDBObjectStore: globalThis.IDBObjectStore,
|
||||
IDBIndex: globalThis.IDBIndex,
|
||||
IDBCursor: globalThis.IDBCursor,
|
||||
IDBVersionChangeEvent: globalThis.IDBVersionChangeEvent,
|
||||
}
|
||||
);
|
||||
pluginGlobalThis.global = pluginGlobalThis;
|
||||
globalThisMap.set(pluginName, pluginGlobalThis);
|
||||
return pluginGlobalThis;
|
||||
};
|
||||
|
||||
const setupPluginCode = async (
|
||||
baseUrl: string,
|
||||
pluginName: string,
|
||||
filename: string
|
||||
) => {
|
||||
await rootImportsMapSetupPromise;
|
||||
if (!_pluginNestedImportsMap.has(pluginName)) {
|
||||
_pluginNestedImportsMap.set(pluginName, new Map());
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const currentImportMap = _pluginNestedImportsMap.get(pluginName)!;
|
||||
const isMissingPackage = (name: string) =>
|
||||
_rootImportsMap.has(name) && !currentImportMap.has(name);
|
||||
|
||||
const bundleAnalysis = await fetch(`${baseUrl}/${filename}.json`).then(
|
||||
res => res.json()
|
||||
);
|
||||
const moduleExports = bundleAnalysis.exports as Record<string, [string]>;
|
||||
const moduleImports = bundleAnalysis.imports as string[];
|
||||
const moduleReexports = bundleAnalysis.reexports as Record<
|
||||
string,
|
||||
[localName: string, exportedName: string][]
|
||||
>;
|
||||
await Promise.all(
|
||||
moduleImports.map(name => {
|
||||
if (isMissingPackage(name)) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
importLogger.debug('missing package', name);
|
||||
return setupPluginCode(baseUrl, pluginName, name);
|
||||
}
|
||||
})
|
||||
);
|
||||
const code = await fetch(
|
||||
`${baseUrl}/${filename.replace(/^\.\//, '')}`
|
||||
).then(res => res.text());
|
||||
importLogger.debug('evaluating', filename);
|
||||
const moduleCompartment = new Compartment(
|
||||
createOrGetGlobalThis(
|
||||
pluginName,
|
||||
// use singleton here to avoid infinite loop
|
||||
createOrGetDynamicImport(baseUrl, pluginName)
|
||||
)
|
||||
);
|
||||
const entryPoint = moduleCompartment.evaluate(code, {
|
||||
__evadeHtmlCommentTest__: true,
|
||||
});
|
||||
const moduleExportsMap = new Map<string, any>();
|
||||
const setVarProxy = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, p: string): any {
|
||||
return (newValue: any) => {
|
||||
moduleExportsMap.set(p, newValue);
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
currentImportMap.set(filename, moduleExportsMap);
|
||||
entryPoint({
|
||||
imports: createImports(pluginName),
|
||||
liveVar: setVarProxy,
|
||||
onceVar: setVarProxy,
|
||||
});
|
||||
|
||||
for (const [newExport, [originalExport]] of Object.entries(moduleExports)) {
|
||||
if (newExport === originalExport) continue;
|
||||
const value = moduleExportsMap.get(originalExport);
|
||||
moduleExportsMap.set(newExport, value);
|
||||
moduleExportsMap.delete(originalExport);
|
||||
}
|
||||
|
||||
for (const [name, reexports] of Object.entries(moduleReexports)) {
|
||||
const targetExports = currentImportMap.get(filename);
|
||||
const moduleExports = currentImportMap.get(name);
|
||||
assertExists(targetExports);
|
||||
assertExists(moduleExports);
|
||||
for (const [exportedName, localName] of reexports) {
|
||||
const exportedValue: any = moduleExports.get(exportedName);
|
||||
assertExists(exportedValue);
|
||||
targetExports.set(localName, exportedValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const PluginProvider = ({ children }: PropsWithChildren) =>
|
||||
createElement(
|
||||
Provider,
|
||||
{
|
||||
store: rootStore,
|
||||
},
|
||||
children
|
||||
);
|
||||
|
||||
const evaluatePluginEntry = (pluginName: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const currentImportMap = _pluginNestedImportsMap.get(pluginName)!;
|
||||
const pluginExports = currentImportMap.get('index.js');
|
||||
assertExists(pluginExports);
|
||||
const entryFunction = pluginExports.get('entry');
|
||||
const cleanup = entryFunction(<PluginContext>{
|
||||
register: (part, callback) => {
|
||||
entryLogger.info(`Registering ${pluginName} to ${part}`);
|
||||
if (part === 'headerItem') {
|
||||
rootStore.set(pluginHeaderItemAtom, items => ({
|
||||
...items,
|
||||
[pluginName]: callback as CallbackMap['headerItem'],
|
||||
}));
|
||||
addCleanup(pluginName, () => {
|
||||
rootStore.set(pluginHeaderItemAtom, items => {
|
||||
const newItems = { ...items };
|
||||
delete newItems[pluginName];
|
||||
return newItems;
|
||||
});
|
||||
});
|
||||
} else if (part === 'editor') {
|
||||
rootStore.set(pluginEditorAtom, items => ({
|
||||
...items,
|
||||
[pluginName]: callback as CallbackMap['editor'],
|
||||
}));
|
||||
addCleanup(pluginName, () => {
|
||||
rootStore.set(pluginEditorAtom, items => {
|
||||
const newItems = { ...items };
|
||||
delete newItems[pluginName];
|
||||
return newItems;
|
||||
});
|
||||
});
|
||||
} else if (part === 'setting') {
|
||||
rootStore.set(pluginSettingAtom, items => ({
|
||||
...items,
|
||||
[pluginName]: callback as CallbackMap['setting'],
|
||||
}));
|
||||
addCleanup(pluginName, () => {
|
||||
rootStore.set(pluginSettingAtom, items => {
|
||||
const newItems = { ...items };
|
||||
delete newItems[pluginName];
|
||||
return newItems;
|
||||
});
|
||||
});
|
||||
} else if (part === 'formatBar') {
|
||||
const register = (widget: AffineFormatBarWidget) => {
|
||||
const div = document.createElement('div');
|
||||
const root = widget.root;
|
||||
const cleanup = (callback as CallbackMap['formatBar'])(
|
||||
div,
|
||||
widget.page,
|
||||
() => {
|
||||
return root.selection.value;
|
||||
}
|
||||
);
|
||||
addCleanup(pluginName, () => {
|
||||
AffineFormatBarWidget.customElements.delete(register);
|
||||
cleanup();
|
||||
});
|
||||
return div;
|
||||
};
|
||||
AffineFormatBarWidget.customElements.add(register);
|
||||
} else {
|
||||
throw new Error(`Unknown part: ${part}`);
|
||||
}
|
||||
},
|
||||
utils: {
|
||||
PluginProvider,
|
||||
},
|
||||
});
|
||||
if (typeof cleanup !== 'function') {
|
||||
throw new Error('Plugin entry must return a function');
|
||||
}
|
||||
addCleanup(pluginName, cleanup);
|
||||
};
|
||||
return {
|
||||
_rootImportsMap,
|
||||
_pluginNestedImportsMap,
|
||||
createImports,
|
||||
createOrGetDynamicImport,
|
||||
setupPluginCode,
|
||||
evaluatePluginEntry,
|
||||
createOrGetGlobalThis,
|
||||
};
|
||||
}
|
||||
134
packages/frontend/core/src/bootstrap/register-plugins.ts
Normal file
134
packages/frontend/core/src/bootstrap/register-plugins.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import {
|
||||
builtinPluginPaths,
|
||||
enabledPluginAtom,
|
||||
invokeCleanup,
|
||||
pluginPackageJson,
|
||||
} from '@toeverything/infra/__internal__/plugin';
|
||||
import {
|
||||
getCurrentStore,
|
||||
loadedPluginNameAtom,
|
||||
} from '@toeverything/infra/atom';
|
||||
import { packageJsonOutputSchema } from '@toeverything/infra/type';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { createSetup } from './plugins/setup';
|
||||
|
||||
const logger = new DebugLogger('register-plugins');
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __pluginPackageJson__: unknown[];
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, '__pluginPackageJson__', {
|
||||
get() {
|
||||
return getCurrentStore().get(pluginPackageJson);
|
||||
},
|
||||
});
|
||||
|
||||
export async function bootstrapPluginSystem(
|
||||
rootStore: ReturnType<typeof getCurrentStore>
|
||||
) {
|
||||
const { evaluatePluginEntry, setupPluginCode } = createSetup(rootStore);
|
||||
rootStore.sub(enabledPluginAtom, () => {
|
||||
const added = new Set<string>();
|
||||
const removed = new Set<string>();
|
||||
const enabledPlugin = new Set(rootStore.get(enabledPluginAtom));
|
||||
enabledPlugin.forEach(pluginName => {
|
||||
if (!enabledPluginSet.has(pluginName)) {
|
||||
added.add(pluginName);
|
||||
}
|
||||
});
|
||||
enabledPluginSet.forEach(pluginName => {
|
||||
if (!enabledPlugin.has(pluginName)) {
|
||||
removed.add(pluginName);
|
||||
}
|
||||
});
|
||||
// update plugins
|
||||
enabledPluginSet.clear();
|
||||
enabledPlugin.forEach(pluginName => {
|
||||
enabledPluginSet.add(pluginName);
|
||||
});
|
||||
added.forEach(pluginName => {
|
||||
evaluatePluginEntry(pluginName);
|
||||
});
|
||||
removed.forEach(pluginName => {
|
||||
invokeCleanup(pluginName);
|
||||
});
|
||||
});
|
||||
const enabledPluginSet = new Set(rootStore.get(enabledPluginAtom));
|
||||
const loadedAssets = new Set<string>();
|
||||
|
||||
// we will load all plugins in parallel from builtinPlugins
|
||||
return Promise.all(
|
||||
[...builtinPluginPaths].map(url => {
|
||||
return fetch(`${url}/package.json`)
|
||||
.then(async res => {
|
||||
const packageJson = (await res.json()) as z.infer<
|
||||
typeof packageJsonOutputSchema
|
||||
>;
|
||||
packageJsonOutputSchema.parse(packageJson);
|
||||
const {
|
||||
name: pluginName,
|
||||
affinePlugin: {
|
||||
release,
|
||||
entry: { core },
|
||||
assets,
|
||||
},
|
||||
} = packageJson;
|
||||
rootStore.set(pluginPackageJson, json => [...json, packageJson]);
|
||||
logger.debug(`registering plugin ${pluginName}`);
|
||||
logger.debug(`package.json: ${packageJson}`);
|
||||
if (!release && !runtimeConfig.enablePlugin) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const baseURL = url;
|
||||
const entryURL = `${baseURL}/${core}`;
|
||||
rootStore.set(loadedPluginNameAtom, prev => [...prev, pluginName]);
|
||||
await setupPluginCode(baseURL, pluginName, core);
|
||||
console.log(`prepareImports for ${pluginName} done`);
|
||||
await fetch(entryURL).then(async () => {
|
||||
if (assets.length > 0) {
|
||||
await Promise.all(
|
||||
assets.map(async (asset: string) => {
|
||||
const loadedAssetName = `${pluginName}_${asset}`;
|
||||
// todo(himself65): add assets into shadow dom
|
||||
if (loadedAssets.has(loadedAssetName)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (asset.endsWith('.css')) {
|
||||
loadedAssets.add(loadedAssetName);
|
||||
const res = await fetch(`${baseURL}/${asset}`);
|
||||
if (res.ok) {
|
||||
// todo: how to put css file into sandbox?
|
||||
return res.text().then(text => {
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('plugin-id', pluginName);
|
||||
style.textContent = text;
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!enabledPluginSet.has(pluginName)) {
|
||||
logger.debug(`plugin ${pluginName} is not enabled`);
|
||||
} else {
|
||||
logger.debug(`plugin ${pluginName} is enabled`);
|
||||
evaluatePluginEntry(pluginName);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(`error when fetch plugin from ${url}`, e);
|
||||
});
|
||||
})
|
||||
).then(() => {
|
||||
console.info('All plugins loaded');
|
||||
});
|
||||
}
|
||||
166
packages/frontend/core/src/bootstrap/setup.ts
Normal file
166
packages/frontend/core/src/bootstrap/setup.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { setupGlobal } from '@affine/env/global';
|
||||
import type { WorkspaceAdapter } from '@affine/env/workspace';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
|
||||
import {
|
||||
type RootWorkspaceMetadataV2,
|
||||
rootWorkspacesMetadataAtom,
|
||||
workspaceAdaptersAtom,
|
||||
} from '@affine/workspace/atom';
|
||||
import {
|
||||
getOrCreateWorkspace,
|
||||
globalBlockSuiteSchema,
|
||||
} from '@affine/workspace/manager';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
migrateLocalBlobStorage,
|
||||
migrateWorkspace,
|
||||
WorkspaceVersion,
|
||||
} from '@toeverything/infra/blocksuite';
|
||||
import { downloadBinary, overwriteBinary } from '@toeverything/y-indexeddb';
|
||||
import type { createStore } from 'jotai/vanilla';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { WorkspaceAdapters } from '../adapters/workspace';
|
||||
|
||||
async function tryMigration() {
|
||||
const value = localStorage.getItem('jotai-workspaces');
|
||||
if (value) {
|
||||
try {
|
||||
const metadata = JSON.parse(value) as RootWorkspaceMetadata[];
|
||||
const promises: Promise<void>[] = [];
|
||||
const newMetadata = [...metadata];
|
||||
metadata.forEach(oldMeta => {
|
||||
if (oldMeta.flavour === WorkspaceFlavour.LOCAL) {
|
||||
let doc: YDoc;
|
||||
const options = {
|
||||
getCurrentRootDoc: async () => {
|
||||
doc = new YDoc({
|
||||
guid: oldMeta.id,
|
||||
});
|
||||
const downloadWorkspace = async (doc: YDoc): Promise<void> => {
|
||||
const binary = await downloadBinary(doc.guid);
|
||||
if (binary) {
|
||||
applyUpdate(doc, binary);
|
||||
}
|
||||
await Promise.all(
|
||||
[...doc.subdocs.values()].map(subdoc =>
|
||||
downloadWorkspace(subdoc)
|
||||
)
|
||||
);
|
||||
};
|
||||
await downloadWorkspace(doc);
|
||||
return doc;
|
||||
},
|
||||
createWorkspace: async () =>
|
||||
getOrCreateWorkspace(nanoid(), WorkspaceFlavour.LOCAL),
|
||||
getSchema: () => globalBlockSuiteSchema,
|
||||
};
|
||||
promises.push(
|
||||
migrateWorkspace(
|
||||
'version' in oldMeta ? oldMeta.version : undefined,
|
||||
options
|
||||
).then(async status => {
|
||||
if (typeof status !== 'boolean') {
|
||||
const adapter = WorkspaceAdapters[oldMeta.flavour];
|
||||
const oldWorkspace = await adapter.CRUD.get(oldMeta.id);
|
||||
const newId = await adapter.CRUD.create(status);
|
||||
assertExists(
|
||||
oldWorkspace,
|
||||
'workspace should exist after migrate'
|
||||
);
|
||||
await adapter.CRUD.delete(oldWorkspace.blockSuiteWorkspace);
|
||||
const index = newMetadata.findIndex(
|
||||
meta => meta.id === oldMeta.id
|
||||
);
|
||||
newMetadata[index] = {
|
||||
...oldMeta,
|
||||
id: newId,
|
||||
version: WorkspaceVersion.Surface,
|
||||
};
|
||||
await migrateLocalBlobStorage(status.id, newId);
|
||||
console.log('workspace migrated', oldMeta.id, newId);
|
||||
} else if (status) {
|
||||
const index = newMetadata.findIndex(
|
||||
meta => meta.id === oldMeta.id
|
||||
);
|
||||
newMetadata[index] = {
|
||||
...oldMeta,
|
||||
version: WorkspaceVersion.Surface,
|
||||
};
|
||||
const overWrite = async (doc: YDoc): Promise<void> => {
|
||||
await overwriteBinary(doc.guid, encodeStateAsUpdate(doc));
|
||||
return Promise.all(
|
||||
[...doc.subdocs.values()].map(subdoc => overWrite(subdoc))
|
||||
).then();
|
||||
};
|
||||
await overWrite(doc);
|
||||
console.log('workspace migrated', oldMeta.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises)
|
||||
.then(() => {
|
||||
console.log('migration done');
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('migration failed', e);
|
||||
})
|
||||
.finally(() => {
|
||||
localStorage.setItem('jotai-workspaces', JSON.stringify(newMetadata));
|
||||
window.dispatchEvent(new CustomEvent('migration-done'));
|
||||
window.$migrationDone = true;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('error when migrating data', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFirstAppData(store: ReturnType<typeof createStore>) {
|
||||
const createFirst = (): RootWorkspaceMetadataV2[] => {
|
||||
const Plugins = Object.values(WorkspaceAdapters).sort(
|
||||
(a, b) => a.loadPriority - b.loadPriority
|
||||
);
|
||||
|
||||
return Plugins.flatMap(Plugin => {
|
||||
return Plugin.Events['app:init']?.().map(
|
||||
id =>
|
||||
<RootWorkspaceMetadataV2>{
|
||||
id,
|
||||
flavour: Plugin.flavour,
|
||||
version: WorkspaceVersion.DatabaseV3,
|
||||
}
|
||||
);
|
||||
}).filter((ids): ids is RootWorkspaceMetadataV2 => !!ids);
|
||||
};
|
||||
if (localStorage.getItem('is-first-open') !== null) {
|
||||
return;
|
||||
}
|
||||
const result = createFirst();
|
||||
console.info('create first workspace', result);
|
||||
localStorage.setItem('is-first-open', 'false');
|
||||
store.set(rootWorkspacesMetadataAtom, result);
|
||||
}
|
||||
|
||||
export async function setup(store: ReturnType<typeof createStore>) {
|
||||
store.set(
|
||||
workspaceAdaptersAtom,
|
||||
WorkspaceAdapters as Record<
|
||||
WorkspaceFlavour,
|
||||
WorkspaceAdapter<WorkspaceFlavour>
|
||||
>
|
||||
);
|
||||
|
||||
console.log('setup global');
|
||||
setupGlobal();
|
||||
|
||||
await tryMigration();
|
||||
// do not read `rootWorkspacesMetadataAtom` before migration
|
||||
await store.get(rootWorkspacesMetadataAtom);
|
||||
console.log('setup done');
|
||||
}
|
||||
Reference in New Issue
Block a user