Compare commits

...

15 Commits

Author SHA1 Message Date
手瓜一十雪
7054535321 Add codeString prop to Snippet for formatted JSON
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Pass JSON.stringify(...) as the codeString prop to Snippet so structured message/response objects are rendered as pretty-printed JSON and can be copied via the tooltip. Changes applied in show_structed_message.tsx, display_card/render.tsx, and display_card/response.tsx.
2026-02-04 12:33:18 +08:00
手瓜一十雪
38f6dad118 Lower NTQQ build check from 41679 to 39038
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Update NTQQFriendApi to use a lower minimum NTQQ build threshold (39038) when deciding which buddyListV2 API path to call. The change replaces occurrences of '41679' with '39038' in packages/napcat-core/apis/friend.ts (affecting getBuddyV2SimpleInfoMap and the buddyListV2 retrieval logic) to broaden compatibility with older NTQQ builds.
2026-02-03 22:56:12 +08:00
手瓜一十雪
c3b29f1ee6 Improve extension tab UI and add /plugin proxy
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Update dashboard extension UI to prevent title overflow: add truncate and max-width to the tab title, include the plugin name in the title tooltip, and hide the plugin-name label on small screens (visible from md+). Also add a '/plugin' proxy entry to the Vite dev server config so plugin requests are forwarded to the backend debug URL.
2026-02-03 18:58:40 +08:00
手瓜一十雪
3e85d18ab5 refactor: serviceworker
重构sw.js,实现更智能的缓存,根据路由设计缓存
2026-02-03 18:09:12 +08:00
手瓜一十雪
df48c01ce4 Add externalVersion and refine API types
Add externalVersion boolean to LoginInitConfig and pass false at initialization; bump appid qua for 9.9.27 entry. Refine several IPC/wrapper typings to use Promise return types, adjust pathIsReadableAndWriteable signature to accept a type and return Promise<number>, and update NodeIKernelNodeMiscService method signatures (styling) while adding getQimei36WithNewSdk(). These changes improve type accuracy for async/native calls and add a new SDK helper.
2026-02-03 15:59:31 +08:00
手瓜一十雪
209776a9e8 Cache QQ installer and handle win64 files
Update GitHub Actions release workflow to use a cached QQ x64 installer (updated NT_URL), add cache key generation and actions/cache usage, and implement a download-and-extract step that reuses the cache. Adjust temporary directory usage (WORK_TMPDIR/NODE_EXTRACT), add LightQuic.dll to copy targets, and copy specific win64 files into an OUT_DIR/win64 folder.

Update packages/napcat-develop/loadNapCat.cjs to recognize and copy win64 artifacts: add TARGET_WIN64_DIR, include LightQuic.dll, define win64ItemsToCopy (SSOShareInfoHelper64.dll, parent-ipc-core-x64.dll), validate their presence, ensure target directories, and copy win64 files into dist/win64. These changes speed CI by caching the installer and ensure required win64 DLLs are packaged alongside the main distribution.
2026-02-03 15:28:13 +08:00
Qiao
09dae7269a
style(webui): 优化插件商店样式,使用固定头部 (#1585)
* fix: 修复 qq_login.tsx 类型错误

- onSelectionChange 的 key 参数可能为 null,添加空值检查

* style(webui): refactor plugin store layout with sticky header
2026-02-03 14:23:13 +08:00
Qiao
2dcf8004ab
fix: 插件更新时保留 data 文件夹 (#1584)
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
* fix: 修复 qq_login.tsx 类型错误

- onSelectionChange 的 key 参数可能为 null,添加空值检查

* fix: 插件更新时保留 data 文件夹

- 更新插件时备份 data 文件夹,解压后恢复

- 添加异常处理,确保解压失败时也能恢复 data 文件夹
2026-02-03 09:55:21 +08:00
手瓜一十雪
74b1da67d8 Add password login support to web UI and backend
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Implement password-based QQ login across the stack: add a PasswordLogin React component, integrate it into the QQ login page, and add a frontend controller method to call a new /QQLogin/PasswordLogin API. On the backend, add QQPasswordLoginHandler, router entry, and WebUiDataRuntime hooks (setPasswordLoginCall / requestPasswordLogin) plus a default handler. Register a password login callback in the shell (base.ts) that calls the kernel login service, handles common error cases and falls back to QR code when needed. Update types to include onPasswordLoginRequested and adjust NodeIKernelLoginService method signatures (including passwordLogin return type changed to Promise<QuickLoginResult>) and minor formatting fixes.
2026-02-02 19:48:31 +08:00
手瓜一十雪
78ac36f670 Add plugin no-auth API routes and WebUI handling
Introduce support for plugin API routes that do not require WebUI authentication. Updates include:

- napcat-onebot: Add apiNoAuth route storage and helpers (apiNoAuth, getNoAuth, postNoAuth, putNoAuth, deleteNoAuth), hasApiNoAuthRoutes, buildApiNoAuthRouter, and clear handling in PluginRouterRegistryImpl.
- napcat-onebot types: Extend PluginRouterRegistry interface with no-auth API methods and document that authenticated APIs remain separate.
- napcat-webui-backend: Mount a new unauthenticated plugin route handler at /plugin/:pluginId/api that looks up the plugin router and dispatches requests to the plugin's no-auth router, returning appropriate errors when context or routes are missing.
- napcat-plugin-builtin: Add example no-auth endpoints (public/info and health) and update logger messages to reflect both auth and no-auth API paths.
- Bump napcat-types version to 0.0.16 and update napcat-plugin-builtin dependency accordingly.

These changes enable plugins to expose public endpoints (e.g. health checks or public metadata) under /plugin/{pluginId}/api/ while keeping existing authenticated APIs under /api/Plugin/ext/{pluginId}/.
2026-02-02 19:13:01 +08:00
手瓜一十雪
74781fda0a Cache object properties to avoid extra RPC
Serialize non-function properties of server-side objects as cachedProps so simple property reads don't require additional RPCs. Added cachedProps to SerializedValue, have RpcServer.storeObjectRef serialize and attach cachedProps (skipping functions), updated serializer to deserialize cachedProps and pass them to proxyCreator, and updated client proxy creation to accept cachedProps and return cached top-level properties directly. Tests updated to expect direct property access for serialized/simple objects and arrays.
2026-02-02 18:59:23 +08:00
手瓜一十雪
3bead89d46 Support object references and deep proxying
Introduce remote object references (refId) and deep proxy support across client, server, serializer and types. Key changes:

- Add refId propagation in client proxies so child proxies inherit and include refId on RPC requests.
- Extend serializer to handle a new SerializedValueType.OBJECT_REF, add refResolver and pass refId to proxyCreator.
- Server: store object references in a Map with generated ref IDs, resolve paths with optional refId, serialize results to OBJECT_REF when shouldProxyResult returns true, and release cleans up references. Add defaultShouldProxyResult heuristic to decide which return values should remain proxied (class instances and objects with methods).
- Types: add refId fields and ObjectRef shape, expose shouldProxyResult option on RpcServerOptions, and include refId in ProxyMeta and serialized values.
- Tests updated across the suite to expect proxied return values (arrays/objects/class instances) and to await property access or method calls; add comprehensive tests for deep return value proxying, chained calls, callbacks, constructors on returned proxies, and lifecycle of remote object proxies.

These changes enable returning live/proxied remote objects (including class instances and objects with methods) from RPC calls, preserving remote behavior and allowing subsequent operations to target the same server-side object.
2026-02-02 18:43:37 +08:00
手瓜一十雪
a4527fd8ca Add napcat-rpc package with deep RPC
Introduce a new napcat-rpc package implementing a deep-proxy RPC system. Adds client (createDeepProxy, proxy helpers), server (RpcServer, createRpcServer), serializer (serialize/deserialize, callback registry), and transport layers (LocalTransport, MessageTransport, message server handler), plus an easy API (createRpcPair, mockRemote, createServer, createClient). Includes TypeScript types, tsconfig and package.json. Wire-up: add package alias in napcat-schema vite config and add napcat-rpc dependency to napcat-test along with comprehensive rpc tests.
2026-02-02 17:12:05 +08:00
手瓜一十雪
52b6627ebd Validate pluginId and use localStorage token
Return a 400 error when the /call-plugin/:pluginId route is requested without a pluginId to avoid calling getPluginExports with an undefined id (packages/napcat-plugin-builtin/index.ts).

Update the dashboard UI to read the auth token from localStorage (same-origin) instead of relying on a URL parameter; a comment about legacy webui_token in the URL was added while the implementation currently prefers localStorage.getItem('token') (packages/napcat-plugin-builtin/webui/dashboard.html).
2026-02-02 16:17:03 +08:00
手瓜一十雪
a5769b6a62 Expose plugin pages at /plugin/:id/page/:path
Add a public route to serve plugin extension pages without auth and update related pieces accordingly. Backend: register GET /plugin/:pluginId/page/:pagePath to locate the plugin router, validate page and HTML file existence, and send the file (returns appropriate 4xx/5xx errors). Frontend: switch iframe and new-window URLs to the new unauthenticated route (remove webui_token usage). Builtin plugin: clarify page registration comment and add a log line for the extension page URL. Minor formatting whitespace tweaks in plugin manager type annotations.
2026-02-02 15:40:18 +08:00
46 changed files with 4461 additions and 242 deletions

View File

@ -125,18 +125,54 @@ jobs:
cd "$TMPDIR"
# -----------------------------
# 1) 下载 QQ x64
# 1) 下载 QQ x64 (使用缓存)
# -----------------------------
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
QQ_ZIP="$(basename "$NT_URL")"
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
# 根据 URL 生成缓存键
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
- name: Cache QQ x64 Installer
id: cache-qq
uses: actions/cache@v4
with:
path: ~/qq-cache
key: ${{ env.QQ_CACHE_KEY }}
- name: Download and Extract QQ x64
run: |
set -euo pipefail
TMPDIR=$(mktemp -d)
cd "$TMPDIR"
QQ_CACHE_DIR="$HOME/qq-cache"
mkdir -p "$QQ_CACHE_DIR"
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
echo "Using cached QQ installer: $QQ_ZIP"
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
else
echo "Downloading QQ installer: $QQ_ZIP"
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
fi
QQ_EXTRACT="$TMPDIR/qq_extracted"
mkdir -p "$QQ_EXTRACT"
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
run: |
set -euo pipefail
cd "$WORK_TMPDIR"
# -----------------------------
# 2) 下载 Node.js Windows x64 zip 22.11.0
@ -146,7 +182,7 @@ jobs:
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
NODE_EXTRACT="$TMPDIR/node_extracted"
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
mkdir -p "$NODE_EXTRACT"
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
@ -164,11 +200,18 @@ jobs:
# -----------------------------
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
# -----------------------------
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
for name in "${QQ_TARGETS[@]}"; do
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
done
# -----------------------------
# 5.1) 拷贝 win64 目录下的文件
# -----------------------------
mkdir -p "$OUT_DIR/win64"
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
# -----------------------------
# 6) 拷贝仓库文件 napcat.bat 和 index.js
# -----------------------------
@ -178,6 +221,7 @@ jobs:
# -----------------------------
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
# -----------------------------
NODE_VER="22.11.0"
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
- name: Upload Artifact

View File

@ -18,7 +18,7 @@ export class NTQQFriendApi {
async getBuddyV2SimpleInfoMap () {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
} else {
@ -55,7 +55,7 @@ export class NTQQFriendApi {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
uids = buddyListV2.flatMap(item => item.buddyUids);
} else {

View File

@ -521,7 +521,7 @@
},
"9.9.27-45627": {
"appid": 537340060,
"qua": "V1_WIN_NQ_9.9.26_45627_GW_B"
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
},
"6.9.88-44725": {
"appid": 537337594,

View File

@ -8,6 +8,7 @@ export interface LoginInitConfig {
commonPath: string;
clientVer: string;
hostName: string;
externalVersion: boolean;
}
export interface PasswordLoginRetType {
@ -21,7 +22,7 @@ export interface PasswordLoginRetType {
jumpWord: string;
tipsTitle: string;
tipsContent: string;
}
};
}
export interface PasswordLoginArgType {
@ -55,37 +56,37 @@ export interface QuickLoginResult {
jumpUrl: string,
jumpWord: string,
tipsTitle: string,
tipsContent: string
tipsContent: string;
};
}
export interface NodeIKernelLoginService {
getMsfStatus: () => number;
setLoginMiscData(arg0: string, value: string): unknown;
setLoginMiscData (arg0: string, value: string): unknown;
getMachineGuid(): string;
getMachineGuid (): string;
get(): NodeIKernelLoginService;
get (): NodeIKernelLoginService;
connect(): boolean;
connect (): boolean;
addKernelLoginListener(listener: NodeIKernelLoginListener): number;
addKernelLoginListener (listener: NodeIKernelLoginListener): number;
removeKernelLoginListener(listener: number): void;
removeKernelLoginListener (listener: number): void;
initConfig(config: LoginInitConfig): void;
initConfig (config: LoginInitConfig): void;
getLoginMiscData(data: string): Promise<GeneralCallResult & { value: string }>;
getLoginMiscData (data: string): Promise<GeneralCallResult & { value: string; }>;
getLoginList(): Promise<{
getLoginList (): Promise<{
result: number, // 0是ok
LocalLoginInfoList: LoginListItem[]
LocalLoginInfoList: LoginListItem[];
}>;
quickLoginWithUin(uin: string): Promise<QuickLoginResult>;
quickLoginWithUin (uin: string): Promise<QuickLoginResult>;
passwordLogin(param: PasswordLoginArgType): Promise<unknown>;
passwordLogin (param: PasswordLoginArgType): Promise<QuickLoginResult>;
getQRCodePicture(): boolean;
getQRCodePicture (): boolean;
}

View File

@ -1,15 +1,17 @@
import { GeneralCallResult } from './common';
export interface NodeIKernelNodeMiscService {
writeVersionToRegistry(version: string): void;
writeVersionToRegistry (version: string): void;
getMiniAppPath(): unknown;
getMiniAppPath (): unknown;
setMiniAppVersion(version: string): unknown;
setMiniAppVersion (version: string): unknown;
wantWinScreenOCR(imagepath: string): Promise<GeneralCallResult>;
wantWinScreenOCR (imagepath: string): Promise<GeneralCallResult>;
SendMiniAppMsg(arg1: string, arg2: string, arg3: string): unknown;
SendMiniAppMsg (arg1: string, arg2: string, arg3: string): unknown;
startNewMiniApp(appfile: string, params: string): unknown;
startNewMiniApp (appfile: string, params: string): unknown;
getQimei36WithNewSdk (): Promise<string>;
}

View File

@ -87,9 +87,9 @@ export interface NodeQQNTWrapperUtil {
fullWordToHalfWord (word: string): unknown;
getNTUserDataInfoConfig (): unknown;
getNTUserDataInfoConfig (): Promise<string>;
pathIsReadableAndWriteable (path: string): unknown; // 直接的猜测
pathIsReadableAndWriteable (path: string, type: number): Promise<number>; // type 2 , result 0 成功
resetUserDataSavePathToDocument (): unknown;
@ -158,7 +158,7 @@ export interface NodeIQQNTStartupSessionWrapper {
stop (): void;
start (): void;
createWithModuleList (uk: unknown): unknown;
getSessionIdList (): unknown;
getSessionIdList (): Promise<Map<unknown, unknown>>;
}
export interface NodeIQQNTWrapperSession {
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;

View File

@ -32,6 +32,7 @@ if (versionFolders.length === 0) {
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
console.log(`BASE_DIR: ${BASE_DIR}`);
const TARGET_DIR = path.join(__dirname, 'dist');
const TARGET_WIN64_DIR = path.join(__dirname, 'dist', 'win64');
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
@ -46,6 +47,12 @@ const itemsToCopy = [
'package.json',
'QBar.dll',
'wrapper.node',
'LightQuic.dll'
];
const win64ItemsToCopy = [
'SSOShareInfoHelper64.dll',
'parent-ipc-core-x64.dll'
];
async function copyAll () {
@ -53,13 +60,23 @@ async function copyAll () {
const configPath = path.join(TARGET_DIR, 'config.json');
const allItemsExist = await fs.pathExists(qqntDllPath) &&
await fs.pathExists(configPath) &&
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists);
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists) &&
(await Promise.all(win64ItemsToCopy.map(item => fs.pathExists(path.join(TARGET_WIN64_DIR, item))))).every(exists => exists);
if (!allItemsExist) {
console.log('Copying required files...');
await fs.ensureDir(TARGET_DIR);
await fs.ensureDir(TARGET_WIN64_DIR);
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
// 复制 win64 目录下的文件
await Promise.all(win64ItemsToCopy.map(async (item) => {
await fs.copy(path.join(BASE_DIR, 'win64', item), path.join(TARGET_WIN64_DIR, item), { overwrite: true });
console.log(`Copied ${item} to win64`);
}));
// 复制根目录下的文件
await Promise.all(itemsToCopy.map(async (item) => {
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
console.log(`Copied ${item}`);

View File

@ -216,7 +216,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
this.pluginRouters.set(entry.id, routerRegistry);
// 创建获取其他插件导出的方法
const getPluginExports = <T = any>(pluginId: string): T | undefined => {
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
const targetEntry = this.plugins.get(pluginId);
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
return undefined;

View File

@ -195,7 +195,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
}
// 创建获取其他插件导出的方法
const getPluginExports = <T = any>(pluginId: string): T | undefined => {
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
const targetEntry = this.plugins.get(pluginId);
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
return undefined;

View File

@ -68,6 +68,7 @@ interface MemoryStaticRoute {
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
private apiRoutes: PluginApiRouteDefinition[] = [];
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
private pageDefinitions: PluginPageDefinition[] = [];
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
private memoryStaticRoutes: MemoryStaticRoute[] = [];
@ -99,6 +100,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
this.api('delete', routePath, handler);
}
// ==================== 无认证 API 路由注册 ====================
apiNoAuth (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuthRoutes.push({ method, path: routePath, handler });
}
getNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('get', routePath, handler);
}
postNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('post', routePath, handler);
}
putNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('put', routePath, handler);
}
deleteNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('delete', routePath, handler);
}
// ==================== 页面注册 ====================
page (pageDef: PluginPageDefinition): void {
@ -184,12 +207,52 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
// ==================== 查询方法 ====================
/**
* API
* API
*/
hasApiRoutes (): boolean {
return this.apiRoutes.length > 0;
}
/**
* API
*/
hasApiNoAuthRoutes (): boolean {
return this.apiNoAuthRoutes.length > 0;
}
/**
* Express Router /plugin/{pluginId}/api/
*/
buildApiNoAuthRouter (): Router {
const router = Router();
for (const route of this.apiNoAuthRoutes) {
const handler = this.wrapHandler(route.handler);
switch (route.method) {
case 'get':
router.get(route.path, handler);
break;
case 'post':
router.post(route.path, handler);
break;
case 'put':
router.put(route.path, handler);
break;
case 'delete':
router.delete(route.path, handler);
break;
case 'patch':
router.patch(route.path, handler);
break;
case 'all':
router.all(route.path, handler);
break;
}
}
return router;
}
/**
*
*/
@ -244,6 +307,7 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
*/
clear (): void {
this.apiRoutes = [];
this.apiNoAuthRoutes = [];
this.pageDefinitions = [];
this.staticRoutes = [];
this.memoryStaticRoutes = [];

View File

@ -140,24 +140,42 @@ export interface MemoryStaticFile {
/** 插件路由注册器 */
export interface PluginRouterRegistry {
// ==================== API 路由注册 ====================
// ==================== API 路由注册(需要认证) ====================
/**
* API
* API /api/Plugin/ext/{pluginId}/
* @param method HTTP
* @param path
* @param handler
*/
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
/** 注册 GET API */
/** 注册 GET API(需要认证) */
get (path: string, handler: PluginRequestHandler): void;
/** 注册 POST API */
/** 注册 POST API(需要认证) */
post (path: string, handler: PluginRequestHandler): void;
/** 注册 PUT API */
/** 注册 PUT API(需要认证) */
put (path: string, handler: PluginRequestHandler): void;
/** 注册 DELETE API */
/** 注册 DELETE API(需要认证) */
delete (path: string, handler: PluginRequestHandler): void;
// ==================== 无认证 API 路由注册 ====================
/**
* API /plugin/{pluginId}/api/
* @param method HTTP
* @param path
* @param handler
*/
apiNoAuth (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
/** 注册 GET API无认证 */
getNoAuth (path: string, handler: PluginRequestHandler): void;
/** 注册 POST API无认证 */
postNoAuth (path: string, handler: PluginRequestHandler): void;
/** 注册 PUT API无认证 */
putNoAuth (path: string, handler: PluginRequestHandler): void;
/** 注册 DELETE API无认证 */
deleteNoAuth (path: string, handler: PluginRequestHandler): void;
// ==================== 页面注册 ====================
/**

View File

@ -129,11 +129,47 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
}
});
// ==================== 无认证 API 路由示例 ====================
// 路由挂载到 /plugin/{pluginId}/api/,无需 WebUI 登录即可访问
// 获取插件公开信息(无需鉴权)
ctx.router.getNoAuth('/public/info', (_req, res) => {
const uptime = Date.now() - startTime;
res.json({
code: 0,
data: {
pluginName: ctx.pluginName,
uptime,
uptimeFormatted: formatUptime(uptime),
platform: process.platform
}
});
});
// 健康检查接口(无需鉴权)
ctx.router.getNoAuth('/health', (_req, res) => {
res.json({
code: 0,
data: {
status: 'ok',
timestamp: new Date().toISOString()
}
});
});
// ==================== 插件互调用示例 ====================
// 演示如何调用其他插件的导出方法
ctx.router.get('/call-plugin/:pluginId', (req, res) => {
const { pluginId } = req.params;
if (!pluginId) {
res.status(400).json({
code: -1,
message: 'Plugin ID is required'
});
return;
}
// 使用 getPluginExports 获取其他插件的导出模块
const targetPlugin = ctx.getPluginExports<PluginModule>(pluginId);
@ -160,7 +196,7 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
});
});
// 注册扩展页面
// 注册扩展页面(无需鉴权,可通过 /plugin/{pluginId}/page/dashboard 访问)
ctx.router.page({
path: 'dashboard',
title: '插件仪表盘',
@ -170,7 +206,9 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
});
logger.info('WebUI 路由已注册:');
logger.info(' - API 路由: /api/Plugin/ext/' + ctx.pluginName + '/');
logger.info(' - API 路由(需认证): /api/Plugin/ext/' + ctx.pluginName + '/');
logger.info(' - API 路由(无认证): /plugin/' + ctx.pluginName + '/api/');
logger.info(' - 扩展页面: /plugin/' + ctx.pluginName + '/page/dashboard');
logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/');
logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/');
};

View File

@ -7,7 +7,7 @@
"description": "NapCat 内置插件",
"author": "NapNeko",
"dependencies": {
"napcat-types": "0.0.15"
"napcat-types": "0.0.16"
},
"devDependencies": {
"@types/node": "^22.0.1"

View File

@ -279,9 +279,10 @@
</div>
<script>
// 从 URL 参数获取 webui_token
// 从 localStorage 获取 token与父页面同源可直接访问
// 兼容旧版:如果 URL 有 webui_token 参数则优先使用
const urlParams = new URLSearchParams(window.location.search);
const webuiToken = urlParams.get('webui_token') || '';
const webuiToken = localStorage.getItem('token') || '';
// 插件 API 基础路径(需要鉴权)
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';

View File

@ -0,0 +1,25 @@
{
"name": "napcat-rpc",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -0,0 +1,366 @@
import {
type DeepProxyOptions,
type ProxyMeta,
RpcOperationType,
PROXY_META,
type RpcRequest,
} from './types.js';
import {
serialize,
deserialize,
SimpleCallbackRegistry,
extractCallbackIds,
} from './serializer.js';
/**
* ID
*/
function generateRequestId (): string {
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
* RPC
*
* 访 RPC
*/
export function createDeepProxy<T = unknown> (options: DeepProxyOptions): T {
const {
transport,
rootPath = [],
refId: rootRefId,
// callbackTimeout 可供未来扩展使用
} = options;
void options.callbackTimeout;
const callbackRegistry = new SimpleCallbackRegistry();
// 注册回调处理器
if (transport.onCallback) {
transport.onCallback(async (callbackId, serializedArgs) => {
const callback = callbackRegistry.get(callbackId);
if (!callback) {
throw new Error(`Callback not found: ${callbackId}`);
}
const args = serializedArgs.map(arg => deserialize(arg, {
callbackResolver: (id) => {
const cb = callbackRegistry.get(id);
if (!cb) throw new Error(`Nested callback not found: ${id}`);
return cb;
},
proxyCreator: (path, proxyRefId, cachedProps) => createProxyAtPath(path, proxyRefId, cachedProps),
}));
const result = await callback(...args);
return serialize(result, { callbackRegistry });
});
}
/**
*
* @param path
* @param refId ID
* @param cachedProps 访 RPC
*/
function createProxyAtPath (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>): unknown {
const proxyMeta: ProxyMeta = {
path: [...path],
isProxy: true,
refId,
};
// 创建一个函数目标,以支持 apply 和 construct
const target = function () { } as unknown as Record<PropertyKey, unknown>;
return new Proxy(target, {
get (_target, prop) {
// 返回代理元数据
if (prop === PROXY_META) {
return proxyMeta;
}
// then 方法特殊处理,使代理可以被 await
if (prop === 'then') {
return undefined;
}
// 检查缓存属性(仅顶层代理,即 path 为空时)
if (path.length === 0 && cachedProps && typeof prop === 'string' && prop in cachedProps) {
return cachedProps[prop];
}
// 返回新的子路径代理(继承 refId不继承 cachedProps
return createProxyAtPath([...path, prop], refId);
},
set (_target, prop, value) {
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.SET,
path: [...path, prop],
args: [serialize(value, { callbackRegistry })],
refId,
};
// 同步返回,但实际是异步操作
transport.send(request).catch(() => { /* ignore */ });
return true;
},
apply (_target, _thisArg, args) {
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
const callbackIds = extractCallbackIds(serializedArgs);
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.APPLY,
path,
args: serializedArgs,
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
refId,
};
return createAsyncResultProxy(request);
},
construct (_target, args): object {
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
const callbackIds = extractCallbackIds(serializedArgs);
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.CONSTRUCT,
path,
args: serializedArgs,
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
refId,
};
return createAsyncResultProxy(request) as object;
},
has (_target, prop) {
// 检查是否为代理元数据符号
if (prop === PROXY_META) {
return true;
}
// 同步返回 true实际检查通过异步完成
return true;
},
ownKeys () {
// 返回空数组,实际键需要通过异步获取
return [];
},
getOwnPropertyDescriptor (_target, _prop) {
return {
configurable: true,
enumerable: true,
writable: true,
};
},
deleteProperty (_target, prop) {
const request: RpcRequest = {
id: generateRequestId(),
type: RpcOperationType.DELETE,
path: [...path, prop],
refId,
};
transport.send(request).catch(() => { /* ignore */ });
return true;
},
getPrototypeOf () {
return Object.prototype;
},
});
}
/**
*
* Promise-like await
* 访
*/
function createAsyncResultProxy (request: RpcRequest): unknown {
let resultPromise: Promise<unknown> | null = null;
const getResult = async (): Promise<unknown> => {
if (!resultPromise) {
resultPromise = (async () => {
const response = await transport.send(request);
if (!response.success) {
const error = new Error(response.error ?? 'RPC call failed');
if (response.stack) {
error.stack = response.stack;
}
throw error;
}
if (response.result === undefined) {
return undefined;
}
// 如果结果是可代理对象,返回代理
if (response.isProxyable && response.result) {
const deserialized = deserialize(response.result, {
callbackResolver: (id) => {
const cb = callbackRegistry.get(id);
if (!cb) throw new Error(`Callback not found: ${id}`);
return cb;
},
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
});
return deserialized;
}
return deserialize(response.result, {
callbackResolver: (id) => {
const cb = callbackRegistry.get(id);
if (!cb) throw new Error(`Callback not found: ${id}`);
return cb;
},
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
});
})();
}
return resultPromise;
};
// 创建一个可链式访问的代理
const target = function () { } as unknown as Record<PropertyKey, unknown>;
return new Proxy(target, {
get (_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
getResult().then(resolve, reject);
};
}
if (prop === 'catch') {
return (reject: (error: unknown) => void) => {
getResult().catch(reject);
};
}
if (prop === 'finally') {
return (callback: () => void) => {
getResult().finally(callback);
};
}
if (prop === PROXY_META) {
return undefined;
}
// 链式访问:等待结果后访问其属性
return createChainedProxy(getResult(), [prop]);
},
apply (_target, _thisArg, args) {
// 等待结果后调用
return getResult().then(result => {
if (typeof result === 'function') {
return result(...args);
}
throw new Error('Result is not callable');
});
},
});
}
/**
*
* await result.prop.method()
*/
function createChainedProxy (parentPromise: Promise<unknown>, path: PropertyKey[]): unknown {
const target = function () { } as unknown as Record<PropertyKey, unknown>;
return new Proxy(target, {
get (_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
parentPromise
.then(parent => {
let value: unknown = parent;
for (const key of path) {
if (value === null || value === undefined) {
return undefined;
}
value = (value as Record<PropertyKey, unknown>)[key];
}
resolve(value);
})
.catch(reject);
};
}
if (prop === 'catch') {
return (reject: (error: unknown) => void) => {
parentPromise.catch(reject);
};
}
if (prop === 'finally') {
return (callback: () => void) => {
parentPromise.finally(callback);
};
}
return createChainedProxy(parentPromise, [...path, prop]);
},
apply (_target, _thisArg, args) {
return parentPromise.then(parent => {
let value: unknown = parent;
const pathToMethod = path.slice(0, -1);
const methodName = path[path.length - 1];
for (const key of pathToMethod) {
if (value === null || value === undefined) {
throw new Error(`Cannot access property '${String(key)}' of ${value}`);
}
value = (value as Record<PropertyKey, unknown>)[key];
}
const method = (value as Record<PropertyKey, unknown>)[methodName!];
if (typeof method !== 'function') {
throw new Error(`${String(methodName)} is not a function`);
}
return method.call(value, ...args);
});
},
});
}
return createProxyAtPath(rootPath, rootRefId) as T;
}
/**
*
*/
export function getProxyMeta (proxy: unknown): ProxyMeta | undefined {
if (proxy != null && (typeof proxy === 'object' || typeof proxy === 'function')) {
try {
// 直接访问 Symbol 属性,代理的 get 陷阱会返回元数据
const meta = (proxy as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
if (meta && meta.isProxy === true) {
return meta;
}
} catch {
// 忽略访问错误
}
}
return undefined;
}
/**
* RPC
*/
export function isRpcProxy (value: unknown): boolean {
return getProxyMeta(value) !== undefined;
}

View File

@ -0,0 +1,130 @@
/**
* RPC API
*
* client/server
* client server
*/
import { LocalTransport } from './transport.js';
import { createDeepProxy, getProxyMeta, isRpcProxy } from './client.js';
import { RpcServer } from './server.js';
import type { ProxyMeta } from './types.js';
/**
* RPC
*/
export interface RpcPair<T> {
/** 客户端代理 - 在这里操作就像直接操作服务端的变量 */
client: T;
/** 服务端原始对象 */
server: T;
/** 关闭连接 */
close (): void;
}
/**
* RPC
*
* client/server client RPC server
*
* @example
* ```ts
* const { client, server } = createRpcPair({
* name: 'test',
* greet: (msg: string) => `Hello, ${msg}!`,
* register: (handlers: { onSuccess: Function, onError: Function }) => {
* handlers.onSuccess('done');
* }
* });
*
* // 在 client 端操作,就像直接操作 server 端的变量
* await client.greet('world'); // 返回 'Hello, world!'
*
* // 支持包含多个回调的对象
* await client.register({
* onSuccess: (result) => console.log(result),
* onError: (err) => console.error(err)
* });
* ```
*/
export function createRpcPair<T extends object> (target: T): RpcPair<T> {
const transport = new LocalTransport(target);
const client = createDeepProxy<T>({ transport });
return {
client,
server: target,
close: () => transport.close(),
};
}
/**
*
*
* "看起来像远程变量" RPC
*
* @example
* ```ts
* const remoteApi = mockRemote({
* counter: 0,
* increment() { return ++this.counter; },
* async fetchData(id: number) { return { id, data: 'test' }; }
* });
*
* // 所有操作都是异步的,通过 RPC 隔离
* await remoteApi.increment(); // 1
* await remoteApi.fetchData(123); // { id: 123, data: 'test' }
* ```
*/
export function mockRemote<T extends object> (target: T): T {
return createRpcPair(target).client;
}
/**
* RPC
*
* @example
* ```ts
* const server = createServer({
* users: new Map(),
* addUser(id: string, name: string) {
* this.users.set(id, { name });
* return true;
* }
* });
*
* // 获取传输层供客户端连接
* const transport = server.getTransport();
* ```
*/
export function createServer<T extends object> (target: T): {
target: T;
handler: RpcServer;
getTransport (): LocalTransport;
} {
const handler = new RpcServer({ target });
return {
target,
handler,
getTransport: () => new LocalTransport(target),
};
}
/**
*
*
* @example
* ```ts
* const server = createServer(myApi);
* const client = createClient<typeof myApi>(server.getTransport());
*
* await client.someMethod();
* ```
*/
export function createClient<T extends object> (transport: LocalTransport): T {
return createDeepProxy<T>({ transport });
}
// 重新导出常用工具
export { getProxyMeta, isRpcProxy };
export type { ProxyMeta };

View File

@ -0,0 +1,60 @@
/**
* napcat-rpc
*
* RPC - RPC
*/
// 简化 API推荐使用
export {
createRpcPair,
mockRemote,
createServer,
createClient,
} from './easy.js';
// 类型导出
export {
RpcOperationType,
SerializedValueType,
PROXY_META,
type RpcRequest,
type RpcResponse,
type SerializedValue,
type RpcTransport,
type RpcServerHandler,
type RpcServerOptions,
type DeepProxyOptions,
type ProxyMeta,
} from './types.js';
// 序列化工具
export {
serialize,
deserialize,
extractCallbackIds,
SimpleCallbackRegistry,
type CallbackRegistry,
type SerializeContext,
type DeserializeContext,
} from './serializer.js';
// 客户端代理
export {
createDeepProxy,
getProxyMeta,
isRpcProxy,
} from './client.js';
// 服务端
export {
RpcServer,
createRpcServer,
} from './server.js';
// 传输层
export {
LocalTransport,
MessageTransport,
createMessageServerHandler,
type MessageTransportOptions,
} from './transport.js';

View File

@ -0,0 +1,410 @@
import {
SerializedValue,
SerializedValueType,
PROXY_META,
type ProxyMeta,
} from './types.js';
/**
*
*/
export interface CallbackRegistry {
register (fn: Function): string;
get (id: string): Function | undefined;
remove (id: string): void;
}
/**
*
*/
export class SimpleCallbackRegistry implements CallbackRegistry {
private callbacks = new Map<string, Function>();
private counter = 0;
register (fn: Function): string {
const id = `cb_${++this.counter}_${Date.now()}`;
this.callbacks.set(id, fn);
return id;
}
get (id: string): Function | undefined {
return this.callbacks.get(id);
}
remove (id: string): void {
this.callbacks.delete(id);
}
clear (): void {
this.callbacks.clear();
}
}
/**
*
*/
export interface SerializeContext {
/** 回调注册器 */
callbackRegistry?: CallbackRegistry;
/** 已序列化对象映射(用于循环引用检测) */
seen?: WeakMap<object, SerializedValue>;
/** 深度限制 */
maxDepth?: number;
/** 当前深度 */
currentDepth?: number;
}
/**
*
*/
export interface DeserializeContext {
/** 回调解析器 */
callbackResolver?: (id: string) => Function;
/** 代理创建器 */
proxyCreator?: (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>) => unknown;
/** 对象引用解析器 */
refResolver?: (refId: string) => unknown;
}
/**
*
*/
export function serialize (value: unknown, context: SerializeContext = {}): SerializedValue {
const {
callbackRegistry,
seen = new WeakMap(),
maxDepth = 50,
currentDepth = 0,
} = context;
// 深度检查
if (currentDepth > maxDepth) {
return { type: SerializedValueType.STRING, value: '[Max depth exceeded]' };
}
// 基本类型处理
if (value === undefined) {
return { type: SerializedValueType.UNDEFINED };
}
if (value === null) {
return { type: SerializedValueType.NULL };
}
const valueType = typeof value;
if (valueType === 'boolean') {
return { type: SerializedValueType.BOOLEAN, value };
}
if (valueType === 'number') {
const numValue = value as number;
if (Number.isNaN(numValue)) {
return { type: SerializedValueType.NUMBER, value: 'NaN' };
}
if (!Number.isFinite(numValue)) {
return { type: SerializedValueType.NUMBER, value: numValue > 0 ? 'Infinity' : '-Infinity' };
}
return { type: SerializedValueType.NUMBER, value };
}
if (valueType === 'bigint') {
return { type: SerializedValueType.BIGINT, value: (value as bigint).toString() };
}
if (valueType === 'string') {
return { type: SerializedValueType.STRING, value };
}
if (valueType === 'symbol') {
return {
type: SerializedValueType.SYMBOL,
value: (value as symbol).description ?? '',
};
}
if (valueType === 'function') {
const fn = value as Function;
if (callbackRegistry) {
const callbackId = callbackRegistry.register(fn);
return {
type: SerializedValueType.FUNCTION,
callbackId,
className: fn.name || 'anonymous',
};
}
return {
type: SerializedValueType.FUNCTION,
className: fn.name || 'anonymous',
};
}
// 对象类型处理
const obj = value as object;
// 检查是否为代理对象
if (PROXY_META in obj) {
const meta = (obj as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
if (meta) {
return {
type: SerializedValueType.PROXY_REF,
proxyPath: meta.path,
};
}
}
// 循环引用检测
if (seen.has(obj)) {
return seen.get(obj)!;
}
// Date
if (obj instanceof Date) {
return { type: SerializedValueType.DATE, value: obj.toISOString() };
}
// RegExp
if (obj instanceof RegExp) {
return {
type: SerializedValueType.REGEXP,
value: { source: obj.source, flags: obj.flags },
};
}
// Error
if (obj instanceof Error) {
return {
type: SerializedValueType.ERROR,
value: obj.message,
className: obj.constructor.name,
properties: {
stack: serialize(obj.stack, { ...context, seen, currentDepth: currentDepth + 1 }),
},
};
}
// Buffer / Uint8Array
if (obj instanceof Uint8Array) {
return {
type: SerializedValueType.BUFFER,
value: Array.from(obj as Uint8Array),
};
}
// Node.js Buffer
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
const BufferClass = (globalThis as unknown as { Buffer: { isBuffer (obj: unknown): boolean; }; }).Buffer;
if (BufferClass.isBuffer(obj)) {
return {
type: SerializedValueType.BUFFER,
value: Array.from(obj as unknown as Uint8Array),
};
}
}
// Map
if (obj instanceof Map) {
const entries: SerializedValue[] = [];
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
for (const [k, v] of obj) {
entries.push(serialize([k, v], nextContext));
}
return {
type: SerializedValueType.MAP,
elements: entries,
};
}
// Set
if (obj instanceof Set) {
const elements: SerializedValue[] = [];
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
for (const v of obj) {
elements.push(serialize(v, nextContext));
}
return {
type: SerializedValueType.SET,
elements,
};
}
// Promise
if (obj instanceof Promise) {
return { type: SerializedValueType.PROMISE };
}
// Array
if (Array.isArray(obj)) {
const result: SerializedValue = {
type: SerializedValueType.ARRAY,
elements: [],
};
seen.set(obj, result);
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
result.elements = obj.map(item => serialize(item, nextContext));
return result;
}
// 普通对象
const result: SerializedValue = {
type: SerializedValueType.OBJECT,
className: obj.constructor?.name ?? 'Object',
properties: {},
};
seen.set(obj, result);
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
for (const key of Object.keys(obj)) {
result.properties![key] = serialize((obj as Record<string, unknown>)[key], nextContext);
}
return result;
}
/**
*
*/
export function deserialize (data: SerializedValue, context: DeserializeContext = {}): unknown {
const { callbackResolver, proxyCreator, refResolver } = context;
switch (data.type) {
case SerializedValueType.UNDEFINED:
return undefined;
case SerializedValueType.NULL:
return null;
case SerializedValueType.BOOLEAN:
return data.value;
case SerializedValueType.NUMBER:
if (data.value === 'NaN') return NaN;
if (data.value === 'Infinity') return Infinity;
if (data.value === '-Infinity') return -Infinity;
return data.value;
case SerializedValueType.BIGINT:
return BigInt(data.value as string);
case SerializedValueType.STRING:
return data.value;
case SerializedValueType.SYMBOL:
return Symbol(data.value as string);
case SerializedValueType.FUNCTION:
if (data.callbackId && callbackResolver) {
return callbackResolver(data.callbackId);
}
// 返回一个占位函数
return function placeholder () {
throw new Error('Remote function cannot be called without callback resolver');
};
case SerializedValueType.DATE:
return new Date(data.value as string);
case SerializedValueType.REGEXP: {
const { source, flags } = data.value as { source: string; flags: string; };
return new RegExp(source, flags);
}
case SerializedValueType.ERROR: {
const error = new Error(data.value as string);
if (data.properties?.['stack']) {
error.stack = deserialize(data.properties['stack'], context) as string;
}
return error;
}
case SerializedValueType.BUFFER: {
const arr = data.value as number[];
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
const BufferClass = (globalThis as unknown as { Buffer: { from (arr: number[]): Uint8Array; }; }).Buffer;
return BufferClass.from(arr);
}
return new Uint8Array(arr);
}
case SerializedValueType.MAP: {
const map = new Map();
if (data.elements) {
for (const element of data.elements) {
const [k, v] = deserialize(element, context) as [unknown, unknown];
map.set(k, v);
}
}
return map;
}
case SerializedValueType.SET: {
const set = new Set();
if (data.elements) {
for (const element of data.elements) {
set.add(deserialize(element, context));
}
}
return set;
}
case SerializedValueType.PROMISE:
return Promise.resolve(undefined);
case SerializedValueType.ARRAY:
return (data.elements ?? []).map(elem => deserialize(elem, context));
case SerializedValueType.PROXY_REF:
if (data.proxyPath && proxyCreator) {
return proxyCreator(data.proxyPath);
}
return {};
case SerializedValueType.OBJECT_REF:
// 对象引用:在客户端创建代理,在服务端解析为实际对象
if (data.refId) {
// 优先使用 refResolver服务端场景
if (refResolver) {
return refResolver(data.refId);
}
// 否则创建代理(客户端场景)
if (proxyCreator) {
// 反序列化缓存的属性
let cachedValues: Record<string, unknown> | undefined;
if (data.cachedProps) {
cachedValues = {};
for (const [key, val] of Object.entries(data.cachedProps)) {
cachedValues[key] = deserialize(val, context);
}
}
return proxyCreator([], data.refId, cachedValues);
}
}
return {};
case SerializedValueType.OBJECT: {
const obj: Record<string, unknown> = {};
if (data.properties) {
for (const [key, val] of Object.entries(data.properties)) {
obj[key] = deserialize(val, context);
}
}
return obj;
}
default:
return undefined;
}
}
/**
* ID映射
*/
export function extractCallbackIds (args: SerializedValue[]): Record<number, string> {
const result: Record<number, string> = {};
args.forEach((arg, index) => {
if (arg.type === SerializedValueType.FUNCTION && arg.callbackId) {
result[index] = arg.callbackId;
}
});
return result;
}

View File

@ -0,0 +1,577 @@
import {
type RpcRequest,
type RpcResponse,
type RpcServerOptions,
type SerializedValue,
RpcOperationType,
SerializedValueType,
} from './types.js';
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
/**
* ID
*/
function generateRefId (): string {
return `ref_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
*
*
* class
*/
function defaultShouldProxyResult (value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value !== 'object' && typeof value !== 'function') {
return false;
}
// 函数保持代理
if (typeof value === 'function') {
return true;
}
// 可安全序列化的内置类型不代理
if (value instanceof Date || value instanceof RegExp || value instanceof Error) {
return false;
}
if (value instanceof Map || value instanceof Set) {
return false;
}
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
return false;
}
// 数组不代理
if (Array.isArray(value)) {
return false;
}
// 检查对象原型是否为 Object.prototype普通对象
const proto = Object.getPrototypeOf(value);
if (proto === Object.prototype || proto === null) {
// 普通对象检查是否有方法
const hasMethod = Object.values(value as object).some(v => typeof v === 'function');
return hasMethod;
}
// 非普通对象class 实例)- 保持代理
return true;
}
/**
* RPC
*
* RPC
*/
export class RpcServer {
private target: unknown;
private callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
private localCallbacks = new SimpleCallbackRegistry();
/** 对象引用存储 */
private objectRefs = new Map<string, unknown>();
/** 代理判断函数 */
private shouldProxyResult: (value: unknown) => boolean;
constructor (options: RpcServerOptions) {
this.target = options.target;
this.callbackInvoker = options.callbackInvoker;
this.shouldProxyResult = options.shouldProxyResult ?? defaultShouldProxyResult;
}
/**
* RPC
*/
async handleRequest (request: RpcRequest): Promise<RpcResponse> {
try {
switch (request.type) {
case RpcOperationType.GET:
return this.handleGet(request);
case RpcOperationType.SET:
return this.handleSet(request);
case RpcOperationType.APPLY:
return await this.handleApply(request);
case RpcOperationType.CONSTRUCT:
return await this.handleConstruct(request);
case RpcOperationType.HAS:
return this.handleHas(request);
case RpcOperationType.OWNKEYS:
return this.handleOwnKeys(request);
case RpcOperationType.DELETE:
return this.handleDelete(request);
case RpcOperationType.GET_DESCRIPTOR:
return this.handleGetDescriptor(request);
case RpcOperationType.GET_PROTOTYPE:
return this.handleGetPrototype(request);
case RpcOperationType.RELEASE:
return this.handleRelease(request);
default:
return {
id: request.id,
success: false,
error: `Unknown operation type: ${request.type}`,
};
}
} catch (error) {
return this.createErrorResponse(request.id, error);
}
}
/**
* refId
*/
private resolvePath (path: PropertyKey[], refId?: string): { parent: unknown; key: PropertyKey | undefined; value: unknown; } {
// 如果有 refId从引用存储中获取根对象
let current = refId ? this.objectRefs.get(refId) : this.target;
if (refId && current === undefined) {
throw new Error(`Object reference not found: ${refId}`);
}
let parent: unknown = null;
let key: PropertyKey | undefined;
for (let i = 0; i < path.length; i++) {
parent = current;
key = path[i];
if (key === undefined) {
throw new Error('Path contains undefined key');
}
if (current === null || current === undefined) {
throw new Error(`Cannot access property '${String(key)}' of ${current}`);
}
current = (current as Record<PropertyKey, unknown>)[key];
}
return { parent, key, value: current };
}
/**
*
* 访 RPC
*/
private storeObjectRef (value: unknown): SerializedValue {
const refId = generateRefId();
this.objectRefs.set(refId, value);
const className = value?.constructor?.name;
// 序列化非函数属性
const cachedProps: Record<string, SerializedValue> = {};
if (value && typeof value === 'object') {
for (const key of Object.keys(value)) {
const propValue = (value as Record<string, unknown>)[key];
// 跳过函数(方法需要远程调用)
if (typeof propValue === 'function') {
continue;
}
// 序列化属性值
try {
cachedProps[key] = serialize(propValue, { callbackRegistry: this.localCallbacks });
} catch {
// 序列化失败的属性跳过,让客户端通过 RPC 获取
}
}
}
return {
type: SerializedValueType.OBJECT_REF,
refId,
className: className !== 'Object' ? className : undefined,
cachedProps: Object.keys(cachedProps).length > 0 ? cachedProps : undefined,
};
}
/**
*
*/
private serializeResult (value: unknown): { result: SerializedValue; isProxyable: boolean; refId?: string; } {
const shouldProxy = this.shouldProxyResult(value);
if (shouldProxy) {
const ref = this.storeObjectRef(value);
return {
result: ref,
isProxyable: true,
refId: ref.refId,
};
}
return {
result: serialize(value, { callbackRegistry: this.localCallbacks }),
isProxyable: false,
};
}
/**
* GET
*/
private handleGet (request: RpcRequest): RpcResponse {
const { value } = this.resolvePath(request.path, request.refId);
const { result, isProxyable, refId } = this.serializeResult(value);
return {
id: request.id,
success: true,
result,
isProxyable,
refId,
};
}
/**
* SET
*/
private handleSet (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0 && !request.refId) {
throw new Error('Cannot set root object');
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
if (parent === null || parent === undefined) {
throw new Error(`Cannot set property '${String(key)}' of ${parent}`);
}
const newValue = request.args?.[0]
? deserialize(request.args[0], {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
: undefined;
(parent as Record<PropertyKey, unknown>)[key] = newValue;
return {
id: request.id,
success: true,
};
}
/**
* APPLY
*/
private async handleApply (request: RpcRequest): Promise<RpcResponse> {
const path = request.path;
// 如果有 refId 且 path 为空,说明引用对象本身是函数
if (path.length === 0 && request.refId) {
const func = this.objectRefs.get(request.refId);
if (typeof func !== 'function') {
throw new Error('Referenced object is not callable');
}
const args = (request.args ?? []).map(arg =>
deserialize(arg, {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
);
let result = func(...args);
if (result instanceof Promise) {
result = await result;
}
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
return {
id: request.id,
success: true,
result: serializedResult,
isProxyable,
refId,
};
}
if (path.length === 0) {
throw new Error('Cannot call root object');
}
const methodPath = path.slice(0, -1);
const methodName = path[path.length - 1]!;
const { value: parent } = this.resolvePath(methodPath, request.refId);
if (parent === null || parent === undefined) {
throw new Error(`Cannot call method on ${parent}`);
}
const method = (parent as Record<PropertyKey, unknown>)[methodName];
if (typeof method !== 'function') {
throw new Error(`${String(methodName)} is not a function`);
}
const args = (request.args ?? []).map(arg =>
deserialize(arg, {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
);
let result = method.call(parent, ...args);
// 处理 Promise
if (result instanceof Promise) {
result = await result;
}
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
return {
id: request.id,
success: true,
result: serializedResult,
isProxyable,
refId,
};
}
/**
* CONSTRUCT
*/
private async handleConstruct (request: RpcRequest): Promise<RpcResponse> {
const { value: Constructor } = this.resolvePath(request.path, request.refId);
if (typeof Constructor !== 'function') {
throw new Error('Target is not a constructor');
}
const args = (request.args ?? []).map(arg =>
deserialize(arg, {
callbackResolver: this.createCallbackResolver(request),
refResolver: (refId) => this.objectRefs.get(refId),
})
);
const instance = new (Constructor as new (...args: unknown[]) => unknown)(...args);
const { result, isProxyable, refId } = this.serializeResult(instance);
return {
id: request.id,
success: true,
result,
isProxyable,
refId,
};
}
/**
* HAS
*/
private handleHas (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0) {
return {
id: request.id,
success: true,
result: serialize(true),
};
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
const has = parent !== null && parent !== undefined && key in (parent as object);
return {
id: request.id,
success: true,
result: serialize(has),
};
}
/**
* OWNKEYS
*/
private handleOwnKeys (request: RpcRequest): RpcResponse {
const { value } = this.resolvePath(request.path, request.refId);
if (value === null || value === undefined) {
return {
id: request.id,
success: true,
result: serialize([]),
};
}
const keys = Reflect.ownKeys(value as object);
return {
id: request.id,
success: true,
result: serialize(keys.map(k => (typeof k === 'symbol' ? k.description ?? '' : String(k)))),
};
}
/**
* DELETE
*/
private handleDelete (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0 && !request.refId) {
throw new Error('Cannot delete root object');
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
if (parent === null || parent === undefined) {
throw new Error(`Cannot delete property from ${parent}`);
}
const deleted = delete (parent as Record<PropertyKey, unknown>)[key];
return {
id: request.id,
success: true,
result: serialize(deleted),
};
}
/**
* GET_DESCRIPTOR
*/
private handleGetDescriptor (request: RpcRequest): RpcResponse {
const path = request.path;
if (path.length === 0) {
return {
id: request.id,
success: true,
result: serialize(undefined),
};
}
const parentPath = path.slice(0, -1);
const key = path[path.length - 1]!;
const { value: parent } = this.resolvePath(parentPath, request.refId);
if (parent === null || parent === undefined) {
return {
id: request.id,
success: true,
result: serialize(undefined),
};
}
const descriptor = Object.getOwnPropertyDescriptor(parent as object, key);
if (!descriptor) {
return {
id: request.id,
success: true,
result: serialize(undefined),
};
}
// 序列化描述符(排除 value 和 get/set 函数)
return {
id: request.id,
success: true,
result: serialize({
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
writable: descriptor.writable,
}),
};
}
/**
* GET_PROTOTYPE
*/
private handleGetPrototype (request: RpcRequest): RpcResponse {
const { value } = this.resolvePath(request.path, request.refId);
if (value === null || value === undefined) {
return {
id: request.id,
success: true,
result: serialize(null),
};
}
const proto = Object.getPrototypeOf(value);
const name = proto?.constructor?.name ?? 'Object';
return {
id: request.id,
success: true,
result: serialize({ name }),
};
}
/**
* RELEASE
*/
private handleRelease (request: RpcRequest): RpcResponse {
// 如果有 refId释放该引用
if (request.refId) {
this.objectRefs.delete(request.refId);
}
return {
id: request.id,
success: true,
};
}
/**
*
*/
private createCallbackResolver (_request: RpcRequest): (id: string) => Function {
return (callbackId: string) => {
// 创建一个代理函数,调用时会通过 callbackInvoker 发送回客户端
return async (...args: unknown[]) => {
if (!this.callbackInvoker) {
throw new Error('Callback invoker not configured');
}
return this.callbackInvoker(callbackId, args);
};
};
}
/**
*
*/
private createErrorResponse (requestId: string, error: unknown): RpcResponse {
if (error instanceof Error) {
return {
id: requestId,
success: false,
error: error.message,
stack: error.stack,
};
}
return {
id: requestId,
success: false,
error: String(error),
};
}
/**
*
*/
async invokeCallback (callbackId: string, args: unknown[]): Promise<SerializedValue> {
if (!this.callbackInvoker) {
throw new Error('Callback invoker not configured');
}
const result = await this.callbackInvoker(callbackId, args);
return serialize(result, { callbackRegistry: this.localCallbacks });
}
}
/**
* RPC
*/
export function createRpcServer (options: RpcServerOptions): RpcServer {
return new RpcServer(options);
}

View File

@ -0,0 +1,204 @@
import {
type RpcTransport,
type RpcRequest,
type RpcResponse,
type SerializedValue,
} from './types.js';
import { RpcServer } from './server.js';
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
/**
*
*
* RPC
*/
export class LocalTransport implements RpcTransport {
private server: RpcServer;
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
private clientCallbacks = new SimpleCallbackRegistry();
constructor (target: unknown) {
this.server = new RpcServer({
target,
callbackInvoker: async (callbackId, args) => {
if (!this.callbackHandler) {
throw new Error('Callback handler not registered');
}
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry: this.clientCallbacks }));
const result = await this.callbackHandler(callbackId, serializedArgs);
return deserialize(result);
},
});
}
async send (request: RpcRequest): Promise<RpcResponse> {
// 模拟网络延迟(可选)
// await new Promise(resolve => setTimeout(resolve, 0));
return this.server.handleRequest(request);
}
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
this.callbackHandler = handler;
}
close (): void {
this.clientCallbacks.clear();
}
}
/**
*
*/
export interface MessageTransportOptions {
/** 发送消息 */
sendMessage: (message: string) => void | Promise<void>;
/** 接收消息时的回调 */
onMessage: (handler: (message: string) => void) => void;
}
/**
*
*
* /
*/
export class MessageTransport implements RpcTransport {
private pendingRequests = new Map<string, {
resolve: (response: RpcResponse) => void;
reject: (error: Error) => void;
}>();
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
private sendMessage: (message: string) => void | Promise<void>;
constructor (options: MessageTransportOptions) {
this.sendMessage = options.sendMessage;
options.onMessage(async (message) => {
const data = JSON.parse(message) as {
type: 'response' | 'callback' | 'callback_response';
id: string;
response?: RpcResponse;
callbackId?: string;
args?: SerializedValue[];
result?: SerializedValue;
error?: string;
};
if (data.type === 'response') {
const pending = this.pendingRequests.get(data.id);
if (pending && data.response) {
this.pendingRequests.delete(data.id);
pending.resolve(data.response);
}
} else if (data.type === 'callback') {
// 处理来自服务端的回调调用
if (this.callbackHandler && data.callbackId && data.args) {
try {
const result = await this.callbackHandler(data.callbackId, data.args);
await this.sendMessage(JSON.stringify({
type: 'callback_response',
id: data.id,
result,
}));
} catch (error) {
await this.sendMessage(JSON.stringify({
type: 'callback_response',
id: data.id,
error: error instanceof Error ? error.message : String(error),
}));
}
}
}
});
}
async send (request: RpcRequest): Promise<RpcResponse> {
return new Promise((resolve, reject) => {
this.pendingRequests.set(request.id, { resolve, reject });
const message = JSON.stringify({
type: 'request',
request,
});
Promise.resolve(this.sendMessage(message)).catch(reject);
});
}
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
this.callbackHandler = handler;
}
close (): void {
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error('Transport closed'));
}
this.pendingRequests.clear();
}
}
/**
*
*/
export function createMessageServerHandler (target: unknown, options: {
sendMessage: (message: string) => void | Promise<void>;
onMessage: (handler: (message: string) => void) => void;
}): void {
const pendingCallbacks = new Map<string, {
resolve: (result: SerializedValue) => void;
reject: (error: Error) => void;
}>();
let callbackIdCounter = 0;
const server = new RpcServer({
target,
callbackInvoker: async (callbackId, args) => {
const id = `cb_call_${++callbackIdCounter}`;
const serializedArgs = args.map(arg => serialize(arg));
return new Promise<unknown>((resolve, reject) => {
pendingCallbacks.set(id, {
resolve: (result) => resolve(deserialize(result)),
reject,
});
options.sendMessage(JSON.stringify({
type: 'callback',
id,
callbackId,
args: serializedArgs,
}));
});
},
});
options.onMessage(async (message) => {
const data = JSON.parse(message) as {
type: 'request' | 'callback_response';
id: string;
request?: RpcRequest;
result?: SerializedValue;
error?: string;
};
if (data.type === 'request' && data.request) {
const response = await server.handleRequest(data.request);
await options.sendMessage(JSON.stringify({
type: 'response',
id: data.request.id,
response,
}));
} else if (data.type === 'callback_response') {
const pending = pendingCallbacks.get(data.id);
if (pending) {
pendingCallbacks.delete(data.id);
if (data.error) {
pending.reject(new Error(data.error));
} else if (data.result) {
pending.resolve(data.result);
}
}
}
});
}

View File

@ -0,0 +1,195 @@
/**
* RPC
*/
export enum RpcOperationType {
/** 获取属性 */
GET = 'get',
/** 设置属性 */
SET = 'set',
/** 调用方法 */
APPLY = 'apply',
/** 构造函数调用 */
CONSTRUCT = 'construct',
/** 检查属性是否存在 */
HAS = 'has',
/** 获取所有键 */
OWNKEYS = 'ownKeys',
/** 删除属性 */
DELETE = 'deleteProperty',
/** 获取属性描述符 */
GET_DESCRIPTOR = 'getOwnPropertyDescriptor',
/** 获取原型 */
GET_PROTOTYPE = 'getPrototypeOf',
/** 回调调用 */
CALLBACK = 'callback',
/** 释放资源 */
RELEASE = 'release',
}
/**
* RPC
*/
export interface RpcRequest {
/** 请求 ID */
id: string;
/** 操作类型 */
type: RpcOperationType;
/** 访问路径 (从根对象开始) */
path: PropertyKey[];
/** 参数 (用于 set, apply, construct) */
args?: SerializedValue[];
/** 回调 ID 映射 (参数索引 -> 回调 ID) */
callbackIds?: Record<number, string>;
/** 远程对象引用 ID用于对引用对象的操作 */
refId?: string;
}
/**
* RPC
*/
export interface RpcResponse {
/** 请求 ID */
id: string;
/** 是否成功 */
success: boolean;
/** 返回值 */
result?: SerializedValue;
/** 错误信息 */
error?: string;
/** 错误堆栈 */
stack?: string;
/** 结果是否为可代理对象 */
isProxyable?: boolean;
/** 远程对象引用 ID用于深层对象代理 */
refId?: string;
}
/**
*
*/
export interface SerializedValue {
/** 值类型 */
type: SerializedValueType;
/** 原始值(用于基本类型) */
value?: unknown;
/** 对象类型名称 */
className?: string;
/** 回调 ID用于函数 */
callbackId?: string;
/** 代理路径(用于可代理对象) */
proxyPath?: PropertyKey[];
/** 数组元素或对象属性 */
properties?: Record<string, SerializedValue>;
/** 数组元素 */
elements?: SerializedValue[];
/** 远程对象引用 ID用于保持代理能力 */
refId?: string;
/** 缓存的属性值OBJECT_REF 时使用,避免属性访问需要 RPC */
cachedProps?: Record<string, SerializedValue>;
}
/**
*
*/
export enum SerializedValueType {
UNDEFINED = 'undefined',
NULL = 'null',
BOOLEAN = 'boolean',
NUMBER = 'number',
BIGINT = 'bigint',
STRING = 'string',
SYMBOL = 'symbol',
FUNCTION = 'function',
OBJECT = 'object',
ARRAY = 'array',
DATE = 'date',
REGEXP = 'regexp',
ERROR = 'error',
PROMISE = 'promise',
PROXY_REF = 'proxyRef',
BUFFER = 'buffer',
MAP = 'map',
SET = 'set',
/** 远程对象引用 - 保持代理能力 */
OBJECT_REF = 'objectRef',
}
/**
*
*/
export interface ObjectRef {
/** 引用 ID */
refId: string;
/** 对象类型名称 */
className?: string;
}
/**
* RPC
*/
export interface RpcTransport {
/** 发送请求并等待响应 */
send (request: RpcRequest): Promise<RpcResponse>;
/** 注册回调处理器 */
onCallback?(handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void;
/** 关闭连接 */
close?(): void;
}
/**
* RPC
*/
export interface RpcServerHandler {
/** 处理请求 */
handleRequest (request: RpcRequest): Promise<RpcResponse>;
/** 调用客户端回调 */
invokeCallback?(callbackId: string, args: unknown[]): Promise<unknown>;
}
/**
*
*/
export interface DeepProxyOptions {
/** 传输层 */
transport: RpcTransport;
/** 根路径 */
rootPath?: PropertyKey[];
/** 是否缓存属性 */
cacheProperties?: boolean;
/** 回调超时时间 (ms) */
callbackTimeout?: number;
/** 远程对象引用 ID用于引用对象的代理 */
refId?: string;
}
/**
* RPC
*/
export interface RpcServerOptions {
/** 目标对象 */
target: unknown;
/** 回调调用器 */
callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
/**
*
* class true
*/
shouldProxyResult?: (value: unknown) => boolean;
}
/**
*
*/
export const PROXY_META = Symbol('PROXY_META');
/**
*
*/
export interface ProxyMeta {
/** 访问路径 */
path: PropertyKey[];
/** 是否为代理 */
isProxy: true;
/** 远程对象引用 ID */
refId?: string;
}

View File

@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": [
"../*/"
]
}
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@ -15,6 +15,7 @@ export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
'@/napcat-common': resolve(__dirname, '../napcat-common'),
'@/napcat-schema': resolve(__dirname, './src'),

View File

@ -109,6 +109,7 @@ async function initializeLoginService (
commonPath: dataPathGlobal,
clientVer: basicInfoWrapper.getFullQQVersion(),
hostName: hostname,
externalVersion: false,
});
}
@ -220,6 +221,52 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
}
});
});
// 注册密码登录回调
WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => {
return await new Promise((resolve) => {
if (uin && passwordMd5) {
logger.log('正在密码登录 ', uin);
loginService.passwordLogin({
uin,
passwordMd5,
step: 0,
newDeviceLoginSig: '',
proofWaterSig: '',
proofWaterRand: '',
proofWaterSid: '',
}).then(res => {
if (res.result === '140022008') {
const errMsg = '需要验证码,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else if (res.result === '140022010') {
const errMsg = '新设备需要扫码登录,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('密码登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '密码登录发生错误' });
});
} else {
resolve({ result: false, message: '密码登录失败:参数不完整' });
}
});
});
if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin);

View File

@ -12,6 +12,7 @@
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-rpc": "workspace:*",
"napcat-image-size": "workspace:*"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ export default defineConfig({
},
resolve: {
alias: {
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
'@/napcat-test': resolve(__dirname, '.'),
'@/napcat-common': resolve(__dirname, '../napcat-common'),

View File

@ -1,6 +1,6 @@
{
"name": "napcat-types",
"version": "0.0.15",
"version": "0.0.16",
"private": false,
"type": "module",
"types": "./napcat-types/index.d.ts",

View File

@ -332,6 +332,64 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
return res.status(404).json({ code: -1, message: 'Memory file not found' });
});
// 插件无认证 API 路由(不需要鉴权)
// 路径格式: /plugin/:pluginId/api/*
app.use('/plugin/:pluginId/api', (req, res, next) => {
const { pluginId } = req.params;
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
const routerRegistry = pluginManager.getPluginRouter(pluginId);
if (!routerRegistry || !routerRegistry.hasApiNoAuthRoutes()) {
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered no-auth API routes` });
}
// 构建并执行插件无认证 API 路由
const pluginRouter = routerRegistry.buildApiNoAuthRouter();
return pluginRouter(req, res, next);
});
// 插件页面路由(不需要鉴权)
// 路径格式: /plugin/:pluginId/page/:pagePath
app.get('/plugin/:pluginId/page/:pagePath', (req, res) => {
const { pluginId, pagePath } = req.params;
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
const routerRegistry = pluginManager.getPluginRouter(pluginId);
if (!routerRegistry || !routerRegistry.hasPages()) {
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
}
const pages = routerRegistry.getPages();
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
if (!page) {
return res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
}
const pluginPath = routerRegistry.getPluginPath();
if (!pluginPath) {
return res.status(500).json({ code: -1, message: 'Plugin path not available' });
}
const htmlFilePath = join(pluginPath, page.htmlFile);
if (!existsSync(htmlFilePath)) {
return res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
}
return res.sendFile(htmlFilePath);
});
// 插件文件系统静态资源路由(不需要鉴权)
// 路径格式: /plugin/:pluginId/files/*
app.use('/plugin/:pluginId/files', (req, res, next) => {

View File

@ -157,6 +157,8 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
const PLUGINS_DIR = getPluginsDir();
const pluginDir = path.join(PLUGINS_DIR, pluginId);
const dataDir = path.join(pluginDir, 'data');
const tempDataDir = path.join(PLUGINS_DIR, `${pluginId}.data.backup`);
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
console.log(`[extractPlugin] pluginId: ${pluginId}`);
@ -169,8 +171,19 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
}
// 如果目录已存在,先删除
// 如果目录已存在,先备份 data 文件夹,再删除
let hasDataBackup = false;
if (fs.existsSync(pluginDir)) {
// 备份 data 文件夹
if (fs.existsSync(dataDir)) {
console.log(`[extractPlugin] Backing up data directory: ${dataDir}`);
if (fs.existsSync(tempDataDir)) {
fs.rmSync(tempDataDir, { recursive: true, force: true });
}
fs.renameSync(dataDir, tempDataDir);
hasDataBackup = true;
}
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
fs.rmSync(pluginDir, { recursive: true, force: true });
}
@ -179,10 +192,35 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
fs.mkdirSync(pluginDir, { recursive: true });
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
// 解压
await compressing.zip.uncompress(zipPath, pluginDir);
try {
// 解压
await compressing.zip.uncompress(zipPath, pluginDir);
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
// 恢复 data 文件夹
if (hasDataBackup && fs.existsSync(tempDataDir)) {
// 如果新版本也有 data 文件夹,先删除
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
console.log(`[extractPlugin] Restoring data directory: ${dataDir}`);
fs.renameSync(tempDataDir, dataDir);
}
} catch (e) {
// 解压失败时,尝试恢复 data 文件夹
if (hasDataBackup && fs.existsSync(tempDataDir)) {
console.log(`[extractPlugin] Extract failed, restoring data directory`);
if (!fs.existsSync(pluginDir)) {
fs.mkdirSync(pluginDir, { recursive: true });
}
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
fs.renameSync(tempDataDir, dataDir);
}
throw e;
}
// 列出解压后的文件
const files = fs.readdirSync(pluginDir);

View File

@ -108,3 +108,29 @@ export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
await WebUiDataRuntime.refreshQRCode();
return sendSuccess(res, null);
};
// 密码登录
export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
// 获取QQ号和密码MD5
const { uin, passwordMd5 } = req.body;
// 判断是否已经登录
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (isLogin) {
return sendError(res, 'QQ Is Logined');
}
// 判断QQ号是否为空
if (isEmpty(uin)) {
return sendError(res, 'uin is empty');
}
// 判断密码MD5是否为空
if (isEmpty(passwordMd5)) {
return sendError(res, 'passwordMd5 is empty');
}
// 执行密码登录
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
if (!result) {
return sendError(res, message);
}
return sendSuccess(res, null);
};

View File

@ -1,24 +1,157 @@
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
const ASSETS_TO_CACHE = [
'/webui/'
];
/**
* NapCat WebUI Service Worker
*
* 路由缓存策略设计
*
* 永不缓存 - Network Only
* - /api/* WebUI API
* - /plugin/:id/api/* 插件 API
* - /files/theme.css CSS
* - /webui/fonts/CustomFont.woff
* - WebSocket / SSE 连接
*
* 强缓存 - Cache First
* - /webui/assets/* hash
* - /webui/fonts/* CustomFont
* - q1.qlogo.cn QQ 头像
*
* 网络优先 - Network First
* - /webui/* (HTML 导航) SPA 页面
* - /plugin/:id/page/* 插件页面
* - /plugin/:id/files/* 插件文件系统静态资源
*
* 后台更新 - Stale-While-Revalidate
* - /plugin/:id/mem/* 插件内存静态资源
*/
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
// 缓存配置
const CACHE_CONFIG = {
// 静态资源缓存最大条目数
MAX_STATIC_ENTRIES: 200,
// QQ 头像缓存最大条目数
MAX_AVATAR_ENTRIES: 100,
// 插件资源缓存最大条目数
MAX_PLUGIN_ENTRIES: 50,
};
// ============ 路由匹配辅助函数 ============
/**
* 检查是否为永不缓存的请求
*/
function isNeverCache (url, request) {
// WebUI API
if (url.pathname.startsWith('/api/')) return true;
// 插件 API: /plugin/:id/api/*
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
// 动态主题 CSS
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
// 用户自定义字体
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
// WebSocket 升级请求
if (request.headers.get('Upgrade') === 'websocket') return true;
// SSE 请求
if (request.headers.get('Accept') === 'text/event-stream') return true;
// Socket 相关
if (url.pathname.includes('/socket')) return true;
return false;
}
/**
* 检查是否为 WebUI 静态资源强缓存
*/
function isWebUIStaticAsset (url) {
// /webui/assets/* - 前端构建产物(带 hash
if (url.pathname.startsWith('/webui/assets/')) return true;
// /webui/fonts/* - 内置字体(排除 CustomFont
if (url.pathname.startsWith('/webui/fonts/') &&
!url.pathname.includes('CustomFont.woff')) return true;
return false;
}
/**
* 检查是否为外部头像强缓存
*/
function isQLogoAvatar (url) {
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
}
/**
* 检查是否为插件文件系统静态资源网络优先
*/
function isPluginStaticFiles (url) {
// /plugin/:id/files/*
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
}
/**
* 检查是否为插件内存静态资源Stale-While-Revalidate
*/
function isPluginMemoryAsset (url) {
// /plugin/:id/mem/*
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
}
/**
* 检查是否为插件页面Network First
*/
function isPluginPage (url) {
// /plugin/:id/page/*
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
}
// ============ 缓存管理函数 ============
/**
* 限制缓存条目数量
*/
async function trimCache (cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// 删除最早的条目
const deleteCount = keys.length - maxEntries;
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i]);
}
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
}
}
/**
* 按类型获取缓存限制
*/
function getCacheLimitForRequest (url) {
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
}
// ============ Service Worker 生命周期 ============
// 安装阶段:预缓存核心文件
self.addEventListener('install', (event) => {
self.skipWaiting(); // 强制立即接管
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// 这里的资源如果加载失败不应该阻断 SW 安装
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
})
);
console.log('[SW] Installing new version:', CACHE_NAME);
self.skipWaiting();
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[SW] Activating new version:', CACHE_NAME);
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
(async () => {
// 清理所有旧版本缓存
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map((cacheName) => {
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
console.log('[SW] Deleting old cache:', cacheName);
@ -26,107 +159,178 @@ self.addEventListener('activate', (event) => {
}
})
);
})
// 立即接管所有客户端
await self.clients.claim();
})()
);
self.clients.claim(); // 立即控制所有客户端
});
// 拦截请求
// ============ 请求拦截 ============
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
const request = event.request;
// 1. API 请求:仅网络 (Network Only)
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
// 1. 永不缓存的请求 - Network Only
if (isNeverCache(url, request)) {
// 不调用 respondWith让请求直接穿透到网络
return;
}
// 2. 强缓存策略 (Cache First)
// - 外部 QQ 头像 (q1.qlogo.cn)
// - 静态资源 (assets, fonts)
// - 常见静态文件后缀
const isQLogo = url.hostname === 'q1.qlogo.cn';
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS不强缓存
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
url.pathname.includes('/webui/fonts/');
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response
// 但 fetch(event.request) 默认会继承 request 的 mode。
// 如果是 img标签发起的请求通常 mode 是 no-cors 或 cors。
// 对于 opaque response (status 0), cache API 允许缓存。
return fetch(event.request).then((response) => {
// 对 qlogo 允许 status 0 (opaque)
// 对其他资源要求 status 200
const isValidResponse = response && (
response.status === 200 ||
response.type === 'basic' ||
(isQLogo && response.type === 'opaque')
);
if (!isValidResponse) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
// 2. WebUI 静态资源 - Cache First
if (isWebUIStaticAsset(url)) {
event.respondWith(cacheFirst(request, url));
return;
}
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
// 3. QQ 头像 - Cache First支持 opaque response
if (isQLogoAvatar(url)) {
event.respondWith(cacheFirstWithOpaque(request, url));
return;
}
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
// 4. 插件文件系统静态资源 - Network First
if (isPluginStaticFiles(url)) {
event.respondWith(networkFirst(request));
return;
}
// 5. 插件内存静态资源 - Stale-While-Revalidate
if (isPluginMemoryAsset(url)) {
event.respondWith(staleWhileRevalidate(request, url));
return;
}
// 6. 插件页面 - Network First
if (isPluginPage(url)) {
event.respondWith(networkFirst(request));
return;
}
// 7. HTML 导航请求 - Network First
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
// 8. 其他同源请求 - Network Only避免意外缓存
if (url.origin === self.location.origin) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return networkResponse;
});
// 如果有缓存,返回缓存;否则等待网络
return cachedResponse || fetchPromise;
})
);
// 不缓存,直接穿透
return;
}
// 默认:网络优先
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
// 9. 其他外部请求 - Network Only
return;
});
// ============ 缓存策略实现 ============
/**
* Cache First 策略
* 优先从缓存返回缓存未命中则从网络获取并缓存
*/
async function cacheFirst (request, url) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
// 异步清理缓存
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache First fetch failed:', error);
return new Response('Network error', { status: 503 });
}
}
/**
* Cache First 策略支持 opaque response用于跨域头像
*/
async function cacheFirstWithOpaque (request, url) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
// opaque response 的 status 是 0但仍可缓存
const isValidResponse = networkResponse && (
networkResponse.status === 200 ||
networkResponse.type === 'opaque'
);
if (isValidResponse) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache First (opaque) fetch failed:', error);
return new Response('Network error', { status: 503 });
}
}
/**
* Network First 策略
* 优先从网络获取网络失败则返回缓存
*/
async function networkFirst (request) {
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('[SW] Network First: network failed, trying cache');
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
return new Response('Offline', { status: 503 });
}
}
/**
* Stale-While-Revalidate 策略
* 立即返回缓存如果有同时后台更新缓存
*/
async function staleWhileRevalidate (request, url) {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
// 后台刷新缓存
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
cache.put(request, networkResponse.clone());
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
}).catch((error) => {
console.log('[SW] SWR background fetch failed:', error);
return null;
});
// 如果有缓存,立即返回缓存
if (cachedResponse) {
return cachedResponse;
}
// 没有缓存,等待网络
const networkResponse = await fetchPromise;
if (networkResponse) {
return networkResponse;
}
return new Response('Network error', { status: 503 });
}

View File

@ -33,6 +33,9 @@ const LoginRuntime: LoginRuntimeType = {
onQuickLoginRequested: async () => {
return { result: false, message: '' };
},
onPasswordLoginRequested: async () => {
return { result: false, message: '密码登录功能未初始化' };
},
onRestartProcessRequested: async () => {
return { result: false, message: '重启功能未初始化' };
},
@ -136,6 +139,14 @@ export const WebUiDataRuntime = {
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
setPasswordLoginCall (func: LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested']): void {
LoginRuntime.NapCatHelper.onPasswordLoginRequested = func;
},
requestPasswordLogin: function (uin: string, passwordMd5: string) {
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
},

View File

@ -10,6 +10,7 @@ import {
getAutoLoginAccountHandler,
setAutoLoginAccountHandler,
QQRefreshQRcodeHandler,
QQPasswordLoginHandler,
} from '@/napcat-webui-backend/src/api/QQLogin';
const router: Router = Router();
@ -31,5 +32,7 @@ router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
// router:刷新QQ登录二维码
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
// router:密码登录
router.post('/PasswordLogin', QQPasswordLoginHandler);
export { router as QQLoginRouter };

View File

@ -56,6 +56,7 @@ export interface LoginRuntimeType {
OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
QQLoginList: string[];

View File

@ -10,6 +10,7 @@ const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
return (
<Snippet
hideSymbol
codeString={JSON.stringify(messages, null, 2)}
tooltipProps={{
content: '点击复制',
}}

View File

@ -121,6 +121,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
<PopoverContent>
<Snippet
hideSymbol
codeString={JSON.stringify(msg, null, 2)}
tooltipProps={{
content: '点击复制',
}}

View File

@ -41,6 +41,7 @@ const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
<PopoverContent>
<Snippet
hideSymbol
codeString={JSON.stringify(data.data, null, 2)}
tooltipProps={{
content: '点击复制',
}}

View File

@ -0,0 +1,122 @@
import { Avatar } from '@heroui/avatar';
import { Button } from '@heroui/button';
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@heroui/dropdown';
import { Image } from '@heroui/image';
import { Input } from '@heroui/input';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { IoChevronDown } from 'react-icons/io5';
import type { QQItem } from '@/components/quick_login';
import { isQQQuickNewItem } from '@/utils/qq';
interface PasswordLoginProps {
onSubmit: (uin: string, password: string) => void;
isLoading: boolean;
qqList: (QQItem | LoginListItem)[];
}
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
const [uin, setUin] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = () => {
if (!uin) {
toast.error('请输入QQ号');
return;
}
if (!password) {
toast.error('请输入密码');
return;
}
onSubmit(uin, password);
};
return (
<div className='flex flex-col gap-8'>
<div className='flex justify-center'>
<Image
className='shadow-lg'
height={100}
radius='full'
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
width={100}
alt="QQ Avatar"
/>
</div>
<div className='flex flex-col gap-4'>
<Input
type="text"
label="QQ账号"
placeholder="请输入QQ号"
value={uin}
onValueChange={setUin}
variant="bordered"
size='lg'
autoComplete="off"
endContent={
<Dropdown>
<DropdownTrigger>
<Button isIconOnly variant="light" size="sm" radius="full">
<IoChevronDown size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="QQ Login History"
items={qqList}
onAction={(key) => setUin(key.toString())}
>
{(item) => (
<DropdownItem key={item.uin} textValue={item.uin}>
<div className='flex items-center gap-2'>
<Avatar
alt={item.uin}
className='flex-shrink-0'
size='sm'
src={
isQQQuickNewItem(item)
? item.faceUrl
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
}
/>
<div className='flex flex-col'>
{isQQQuickNewItem(item)
? `${item.nickName}(${item.uin})`
: item.uin}
</div>
</div>
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
}
/>
<Input
type="password"
label="密码"
placeholder="请输入密码"
value={password}
onValueChange={setPassword}
variant="bordered"
size='lg'
autoComplete="new-password"
/>
</div>
<div className='flex justify-center mt-5'>
<Button
className='w-64 max-w-full'
color='primary'
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
onPress={handleSubmit}
>
</Button>
</div>
</div>
);
};
export default PasswordLogin;

View File

@ -93,4 +93,11 @@ export default class QQManager {
uin,
});
}
public static async passwordLogin (uin: string, passwordMd5: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
uin,
passwordMd5,
});
}
}

View File

@ -63,13 +63,12 @@ export default function ExtensionPage () {
}, [extensionPages]);
// 获取当前选中页面的 iframe URL
// 新路由格式不需要鉴权: /plugin/:pluginId/page/:pagePath
const currentPageUrl = useMemo(() => {
if (!selectedTab) return '';
const [pluginId, ...pathParts] = selectedTab.split(':');
const path = pathParts.join(':').replace(/^\//, '');
// 获取认证 token
const token = localStorage.getItem('token') || '';
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${token}`;
return `/plugin/${pluginId}/page/${path}`;
}, [selectedTab]);
useEffect(() => {
@ -86,11 +85,10 @@ export default function ExtensionPage () {
setIframeLoading(false);
};
// 在新窗口打开页面
// 在新窗口打开页面(新路由不需要鉴权)
const openInNewWindow = (pluginId: string, path: string) => {
const cleanPath = path.replace(/^\//, '');
const token = localStorage.getItem('token') || '';
const url = `/api/Plugin/page/${pluginId}/${cleanPath}?webui_token=${token}`;
const url = `/plugin/${pluginId}/page/${cleanPath}`;
window.open(url, '_blank');
};
@ -134,8 +132,8 @@ export default function ExtensionPage () {
<div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>}
<span
className='cursor-pointer hover:underline'
title='点击在新窗口打开'
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(tab.pluginId, tab.path);
@ -143,7 +141,7 @@ export default function ExtensionPage () {
>
{tab.title}
</span>
<span className='text-xs text-default-400'>({tab.pluginName})</span>
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
</div>
}
/>

View File

@ -9,6 +9,7 @@ import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import clsx from 'clsx';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { useLocalStorage } from '@uidotdev/usehooks';
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
@ -226,68 +227,70 @@ export default function PluginStorePage () {
}
};
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative">
{/* 头部 */}
<div className="flex mb-6 items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={24} />
</Button>
{/* 固定头部区域 */}
<div className={clsx(
'sticky top-14 z-10 backdrop-blur-sm py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
hasBackground
? 'bg-white/20 dark:bg-black/10'
: 'bg-transparent'
)}>
{/* 头部 */}
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={24} />
</Button>
</div>
{/* 商店列表源卡片 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
</div>
<Tooltip content="切换列表源">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div>
{/* 商店列表源卡片 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
</div>
<Tooltip content="切换列表源">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div>
{/* 搜索框 */}
<div className="mb-6">
<Input
placeholder="搜索插件名称、描述、作者或标签..."
startContent={<IoMdSearch className="text-default-400" />}
value={searchQuery}
onValueChange={setSearchQuery}
className="max-w-md"
/>
</div>
{/* 标签页 */}
<div className="relative">
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<Spinner size='lg' />
</div>
)}
{/* 搜索框 */}
<div className="mb-4">
<Input
placeholder="搜索插件名称、描述、作者或标签..."
startContent={<IoMdSearch className="text-default-400" />}
value={searchQuery}
onValueChange={setSearchQuery}
className="max-w-md"
/>
</div>
{/* 标签页导航 */}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
@ -296,32 +299,43 @@ export default function PluginStorePage () {
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
>
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[tab.key]?.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</Tab>
/>
))}
</Tabs>
</div>
{/* 插件列表区域 */}
<div className="relative">
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<Spinner size='lg' />
</div>
)}
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[activeTab]?.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
/>
);
})}
</div>
</div>
</div>
{/* 商店列表源选择弹窗 */}

View File

@ -5,11 +5,13 @@ import { Tab, Tabs } from '@heroui/tabs';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
import CryptoJS from 'crypto-js';
import logo from '@/assets/images/logo.png';
import HoverEffectCard from '@/components/effect_card';
import { title } from '@/components/primitives';
import PasswordLogin from '@/components/password_login';
import QrCodeLogin from '@/components/qr_code_login';
import QuickLogin from '@/components/quick_login';
import type { QQItem } from '@/components/quick_login';
@ -51,6 +53,7 @@ export default function QQLoginPage () {
const lastErrorRef = useRef<string>('');
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
const [refresh, setRefresh] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<string>('shortcut');
const firstLoad = useRef<boolean>(true);
const onSubmit = async () => {
if (!uinValue) {
@ -72,6 +75,21 @@ export default function QQLoginPage () {
}
};
const onPasswordSubmit = async (uin: string, password: string) => {
setIsLoading(true);
try {
// 计算密码的MD5值
const passwordMd5 = CryptoJS.MD5(password).toString();
await QQManager.passwordLogin(uin, passwordMd5);
toast.success('密码登录请求已发送');
} catch (error) {
const msg = (error as Error).message;
toast.error(`密码登录失败: ${msg}`);
} finally {
setIsLoading(false);
}
};
const onUpdateQrCode = async () => {
if (firstLoad.current) setIsLoading(true);
try {
@ -91,11 +109,17 @@ export default function QQLoginPage () {
setLoginError(data.loginError);
const friendlyMsg = parseLoginError(data.loginError);
dialog.alert({
title: '登录失败',
content: friendlyMsg,
confirmText: '确定',
});
// 仅在扫码登录 Tab 下才弹窗,或者错误不是"二维码已过期"
// 如果是 "二维码已过期",且不在 qrcode tab则不弹窗
const isQrCodeExpired = friendlyMsg.includes('二维码') && (friendlyMsg.includes('过期') || friendlyMsg.includes('失效'));
if (!isQrCodeExpired || activeTab === 'qrcode') {
dialog.alert({
title: '登录失败',
content: friendlyMsg,
confirmText: '确定',
});
}
} else if (!data.loginError) {
lastErrorRef.current = '';
setLoginError('');
@ -197,6 +221,8 @@ export default function QQLoginPage () {
}}
isDisabled={isLoading}
size='lg'
selectedKey={activeTab}
onSelectionChange={(key) => key !== null && setActiveTab(key.toString())}
>
<Tab key='shortcut' title='快速登录'>
<QuickLogin
@ -209,6 +235,13 @@ export default function QQLoginPage () {
onUpdateQQList={onUpdateQQList}
/>
</Tab>
<Tab key='password' title='密码登录'>
<PasswordLogin
isLoading={isLoading}
onSubmit={onPasswordSubmit}
qqList={qqList}
/>
</Tab>
<Tab key='qrcode' title='扫码登录'>
<QrCodeLogin
loginError={parseLoginError(loginError)}

View File

@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => {
},
'/api': backendDebugUrl,
'/files': backendDebugUrl,
'/plugin': backendDebugUrl,
'/webui/fonts/CustomFont.woff': backendDebugUrl,
'/webui/sw.js': backendDebugUrl,
},

View File

@ -232,8 +232,8 @@ importers:
packages/napcat-plugin-builtin:
dependencies:
napcat-types:
specifier: 0.0.15
version: 0.0.15
specifier: 0.0.16
version: 0.0.16
devDependencies:
'@types/node':
specifier: ^22.0.1
@ -302,6 +302,12 @@ importers:
specifier: ^22.0.1
version: 22.19.1
packages/napcat-rpc:
devDependencies:
'@types/node':
specifier: ^22.0.1
version: 22.19.1
packages/napcat-schema:
dependencies:
'@sinclair/typebox':
@ -357,6 +363,9 @@ importers:
napcat-image-size:
specifier: workspace:*
version: link:../napcat-image-size
napcat-rpc:
specifier: workspace:*
version: link:../napcat-rpc
devDependencies:
vitest:
specifier: ^4.0.9
@ -5448,8 +5457,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napcat-types@0.0.15:
resolution: {integrity: sha512-uOkaQPO3SVgkO/Rt0cQ+02wCI9C9jzdYVViHByHrr9sA+2ZjT1HV5nVSgNNQXUaZ9q405LUu45xQ4lysNyLpBA==}
napcat-types@0.0.16:
resolution: {integrity: sha512-y3qhpdd16ATsMp4Jf88XwisFBVKqY+XSfvGX1YqMEasVFTNXeKr1MZrIzhHMkllW1QJZXAI8iNGVJO1gkHEtLQ==}
napcat.protobuf@1.1.4:
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
@ -12774,7 +12783,7 @@ snapshots:
nanoid@3.3.11: {}
napcat-types@0.0.15:
napcat-types@0.0.16:
dependencies:
'@sinclair/typebox': 0.34.41
'@types/node': 22.19.1