diff --git a/README.md b/README.md index 58a3fe5..a14fa36 100644 --- a/README.md +++ b/README.md @@ -16,19 +16,30 @@ cd examples/basic-admin && npm install && npm start ```sh # 推荐: HTTP(任何能访问 git.zeromvc.com 的机器都能用) -npm install git+http://git.zeromvc.com/wc24/shrimp-server.git#v0.1.0 +npm install git+http://git.zeromvc.com/wc24/shrimp-server.git#v0.2.0 # 也可以 SSH(需要 ssh key 配好) -npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.1.0 +npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.2.0 ``` > 仓库 ship `.ts` 源码,消费方项目需要 TS 运行器(`tsx` / `ts-node`)或在打包工具(vite / webpack / esbuild)里能解析 `.ts`。 +### 两个入口,按需选择 + +包内提供了**核心**和**完整 server** 两个 entry,**peer deps 全部 optional**——你只装自己用得到的那一份: + ```ts -import { ShrimpServer, ShrimpTableModule, MongoShrimpCrud, ShrimpFieldTypes } from "shrimp-server" -// ↓ 详见下面"想做什么"指南 +// 1) 核心: 0 重依赖(只需 node + tsx),适合自己接 HTTP / 只用内存 CRUD / 嵌入其它框架 +import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "shrimp-server" + +// 2) 完整 server: 自带 Express Router + Mongoose + JWT,需要装上面 4 个 peer +import { ShrimpServer, MongoShrimpCrud } from "shrimp-server/server" ``` +只用 (1) 时,**不要**在自己的 package.json 里写 express/mongoose/bcrypt/jsonwebtoken,可以省下大量 install 时间(尤其是 mongoose 的 native binding)。需要 (2) 时按 peer 范围装齐即可。 + +> bcrypt 在 core 里走的是动态 `import('bcrypt')`,不触发 Password 字段 hash / `UserModule.verifyPassword` 就完全不会去 require 它。所以即便用 core 也注册了 password 字段,只要你不真正调用 `add/edit` 写入 Password 或 `getUser` 校验密码,bcrypt 仍是可不装的。 + --- # 使用指南 diff --git a/examples/basic-admin/index.ts b/examples/basic-admin/index.ts index 7edc52d..d85b788 100644 --- a/examples/basic-admin/index.ts +++ b/examples/basic-admin/index.ts @@ -61,16 +61,10 @@ const superAdmin: IShrimpOperator = { async function bootstrap() { const shrimp = new Shrimp() - // TODO(70→90): ShrimpTableModule 当前只在 dataSource.model 存在时自动登记 crudRegistry, - // 这里需要手动 wire 让 MemoryShrimpCrud 的 populate 也能查到关联表 const tagCrud = new MemoryShrimpCrud>() - tagCrud.getRelatedCrud = (k) => shrimp.crudRegistry.get(k) - shrimp.crudRegistry.set("tag", tagCrud) new ShrimpTableModule(shrimp, "tag", tagCrud, tagFields, "标签管理").initCrud() const productCrud = new MemoryShrimpCrud>() - productCrud.getRelatedCrud = (k) => shrimp.crudRegistry.get(k) - shrimp.crudRegistry.set("product", productCrud) new ShrimpTableModule(shrimp, "product", productCrud, productFields, "商品管理").initCrud() // 种子数据 diff --git a/package.json b/package.json index 51dc430..9318652 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,21 @@ { "name": "shrimp-server", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/core.ts", + "default": "./src/core.ts" + }, + "./server": { + "types": "./src/server.ts", + "default": "./src/server.ts" + }, + "./package.json": "./package.json" + }, "files": [ "src", "README.md" @@ -21,6 +32,12 @@ "jsonwebtoken": "^8 || ^9", "bcrypt": "^5 || ^6" }, + "peerDependenciesMeta": { + "express": { "optional": true }, + "mongoose": { "optional": true }, + "jsonwebtoken": { "optional": true }, + "bcrypt": { "optional": true } + }, "devDependencies": { "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.0", diff --git a/src/MemoryShrimpCrud.ts b/src/MemoryShrimpCrud.ts index a64daf9..4ca5fed 100644 --- a/src/MemoryShrimpCrud.ts +++ b/src/MemoryShrimpCrud.ts @@ -4,8 +4,6 @@ import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, isShrimpC export class MemoryShrimpCrud implements ShrimpCrud { private data: Map = new Map() private autoId: number = 1 - /** 获取关联模块的数据源(用于 populate) */ - getRelatedCrud?: (moduleKey: string) => ShrimpCrud | undefined private isCondition = isShrimpCondition @@ -80,9 +78,10 @@ export class MemoryShrimpCrud implements ShrimpCrud { + async populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map>): Promise { + if (!crudRegistry) return for (const pop of populates) { - const relatedCrud = this.getRelatedCrud?.(pop.moduleKey) + const relatedCrud = crudRegistry.get(pop.moduleKey) if (!relatedCrud) continue const allRelated = (await relatedCrud.list({})).list const labelMap = new Map(allRelated.map(item => [String(item._id), (item as any)[pop.labelKey]])) diff --git a/src/Shrimp.ts b/src/Shrimp.ts index a02a22e..eaf1158 100644 --- a/src/Shrimp.ts +++ b/src/Shrimp.ts @@ -5,9 +5,14 @@ import { ShrimpCode, IShrimpResponse, ITableCrudProto, IModuleInfo, IUser, IRole, WithId, IRoleProto, IPageQuery, IPageResult, IFieldConfig, IFieldRule, IFieldTypeHandler, IShrimpQuery, IShrimpCondition, IShrimpPopulate, ShrimpConfig, IShrimpOperator, IMenuItem, ShrimpError, ShrimpFieldTypes, ILabel } from "./IShrimpType" -import * as bcrypt from "bcrypt" import { AsyncLocalStorage } from 'async_hooks' +// 懒加载 bcrypt: 只有走到 Password 字段 hash 或 verifyPassword 才 import, +// 让纯核心(MemoryShrimpCrud + 业务模块)的消费方可以不装 bcrypt +async function loadBcrypt(): Promise { + return await import("bcrypt") +} + export class Shrimp { root: Shrimp = this modules: Map = new Map() @@ -253,6 +258,7 @@ export const defaultTypeHandlers: Record = { [ShrimpFieldTypes.Password]: { reqFilter: async (value) => { if (typeof value === 'string' && value.length > 0) { + const bcrypt = await loadBcrypt() return bcrypt.hash(value, 10) } return value @@ -318,10 +324,8 @@ export class ShrimpTableModule = ITableCrudProto if (fields) { this.fields = fields } - // 自动注册 CRUD 到 root.crudRegistry,供 MongoShrimpCrud.populateItems 自动解析 relation - if ((dataSource as any).model) { - this.root.crudRegistry.set(moduleKey, dataSource) - } + // 自动注册 CRUD 到 root.crudRegistry,populateItems 据此解析 relation + this.root.crudRegistry.set(moduleKey, dataSource) } getFields(): IFieldConfig[] { @@ -711,6 +715,7 @@ export class UserModule extends ShrimpTableModule> { /** 校验密码,子类可覆盖 */ protected async verifyPassword(storedPassword: string, plainPassword: string): Promise { + const bcrypt = await loadBcrypt() return bcrypt.compare(plainPassword, storedPassword) } diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..78e34bf --- /dev/null +++ b/src/core.ts @@ -0,0 +1,78 @@ +/** + * Shrimp 核心入口 — 0 重依赖 + * + * 只暴露 Shrimp 框架本体 + 内存数据源 + 类型定义,不引入 + * express / mongoose / jsonwebtoken。bcrypt 是 lazy import, + * 不走 Password 字段就不会触发。 + * + * 适用场景: + * - 只想用 Shrimp 的 RPC dispatch + 模块/字段声明 + 内存 CRUD + * - 自己接 HTTP / 自己写存储层 + * - 跑测试 / 跑无 DB 的 demo + * + * 需要完整后台(Express + Mongoose + JWT)请从 'shrimp-server/server' 导入。 + */ + +// ===== 类型 / 接口 / 枚举 ===== +export { + ShrimpCode, + ShrimpError, + ShrimpFieldTypes, + ShrimpConfig, + isShrimpCondition, +} from "./IShrimpType" + +export type { + ILabel, + IShrimpResponse, + IModuleInfo, + ILoginResult, + IMeResult, + IMenuItem, + ITableProto, + ITableCrudProto, + IUser, + IRole, + IOperationLog, + WithId, + IShrimpOperator, + IShrimpPowerData, + ShrimpRequestFn, + IShrimpClient, + ShrimpFieldType, + FieldEditable, + IFieldTypeHandler, + IFieldConfig, + IFieldRule, + IPageQuery, + IPageResult, + IShrimpCondition, + IShrimpPopulate, + IShrimpQuery, + IPowerItem, + IUserProto, + IRoleProto, +} from "./IShrimpType" + +// ===== 框架核心 ===== +export { + Shrimp, + ShrimpModule, + ShrimpTableModule, + ShrimpPower, + RootShrimpPower, + UserModule, + RoleModule, + defaultTypeHandlers, +} from "./Shrimp" + +export type { + IShrimpModuleOptions, + ShrimpCrud, +} from "./Shrimp" + +// ===== 数据源(纯内存,无外部依赖) ===== +export { MemoryShrimpCrud } from "./MemoryShrimpCrud" + +// ===== 可选模块(纯框架代码,无外部依赖) ===== +export { OperationLogModule } from "./OperationLogModule" diff --git a/src/index.ts b/src/index.ts index bacadd9..cdab37c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,78 +1,10 @@ /** - * Shrimp Server — 公共 API + * 默认入口 = 核心 (0 重依赖) * - * 内部实现细节(如 ShrimpEvent)未在此处导出,业务请只依赖本文件再导出的符号。 + * - import ... from 'shrimp-server' => 这个文件 / src/core.ts(纯框架,无 peer) + * - import ... from 'shrimp-server/server' => src/server.ts(再带 ShrimpServer + Mongo) + * + * 该文件存在的意义是兼容不识别 `package.json#exports` 的旧工具链(npm < 7、 + * 部分 bundler 的 legacy resolve),让 `main: src/index.ts` 也能正常工作。 */ - -// ===== 类型 / 接口 / 枚举 ===== -export { - ShrimpCode, - ShrimpError, - ShrimpFieldTypes, - ShrimpConfig, - isShrimpCondition, -} from "./IShrimpType" - -export type { - ILabel, - IShrimpResponse, - IModuleInfo, - ILoginResult, - IMeResult, - IMenuItem, - ITableProto, - ITableCrudProto, - IUser, - IRole, - IOperationLog, - WithId, - IShrimpOperator, - IShrimpPowerData, - ShrimpRequestFn, - IShrimpClient, - ShrimpFieldType, - FieldEditable, - IFieldTypeHandler, - IFieldConfig, - IFieldRule, - IPageQuery, - IPageResult, - IShrimpCondition, - IShrimpPopulate, - IShrimpQuery, - IPowerItem, - IUserProto, - IRoleProto, -} from "./IShrimpType" - -// ===== 框架核心 ===== -export { - Shrimp, - ShrimpModule, - ShrimpTableModule, - ShrimpPower, - RootShrimpPower, - UserModule, - RoleModule, - defaultTypeHandlers, -} from "./Shrimp" - -export type { - IShrimpModuleOptions, - ShrimpCrud, -} from "./Shrimp" - -// ===== 服务端实现(Express + Mongoose + JWT) ===== -export { - ShrimpServer, - UserSchema, - RoleSchema, - OperationLogSchema, -} from "./ShrimpServer" - -// ===== 数据源 ===== -export { MongoShrimpCrud } from "./MongoShrimpCrud" -export { MemoryShrimpCrud } from "./MemoryShrimpCrud" - -// ===== 可选模块 ===== -export { OperationLogModule } from "./OperationLogModule" +export * from "./core" diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..b0d5da3 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,25 @@ +/** + * Shrimp 服务端入口 — 包含 Express + Mongoose + JWT 实现 + * + * 在 core 之上额外暴露: + * - ShrimpServer:Express Router + JWT 鉴权 + Mongo 连接 + * - MongoShrimpCrud:Mongoose 数据源 + * - 内置 User/Role/OperationLog 的 Mongoose Schema + * + * 这一入口需要消费方安装 peer 依赖:express / mongoose / jsonwebtoken / bcrypt。 + * 不需要这些时请改从 'shrimp-server' 导入,不会触发任何 peer。 + */ + +// 再导出所有 core 的符号,'shrimp-server/server' 单一入口就够用 +export * from "./core" + +// ===== 服务端实现(Express + Mongoose + JWT) ===== +export { + ShrimpServer, + UserSchema, + RoleSchema, + OperationLogSchema, +} from "./ShrimpServer" + +// ===== Mongoose 数据源 ===== +export { MongoShrimpCrud } from "./MongoShrimpCrud" diff --git a/test/MemoryShrimpCrud.test.ts b/test/MemoryShrimpCrud.test.ts index 0c0274a..7a416ac 100644 --- a/test/MemoryShrimpCrud.test.ts +++ b/test/MemoryShrimpCrud.test.ts @@ -109,17 +109,25 @@ describe("MemoryShrimpCrud", () => { expect(result.total).toBe(0) }) - it("populateItems 旁挂 _label", async () => { + it("populateItems 旁挂 _label (通过 crudRegistry 解析)", async () => { const tagCrud = new MemoryShrimpCrud<{ _id: string; name: string }>() await tagCrud.add({ name: "热门" } as any) await tagCrud.add({ name: "促销" } as any) const itemCrud = new MemoryShrimpCrud() - itemCrud.getRelatedCrud = (key) => key === "tag" ? tagCrud as any : undefined const a = await itemCrud.add({ name: "苹果", price: 5, tags: ["1", "2"] } as any) + const registry = new Map([["tag", tagCrud]]) const items = [{ ...a }] as any[] - await itemCrud.populateItems(items, [{ moduleKey: "tag", key: "tags", labelKey: "name" }]) + await itemCrud.populateItems(items, [{ moduleKey: "tag", key: "tags", labelKey: "name" }], registry) expect(items[0].tags_label).toEqual(["热门", "促销"]) }) + + it("populateItems 不传 crudRegistry 静默跳过(不抛错,不旁挂)", async () => { + const itemCrud = new MemoryShrimpCrud() + const a = await itemCrud.add({ name: "苹果", price: 5, tags: ["1"] } as any) + const items = [{ ...a }] as any[] + await itemCrud.populateItems(items, [{ moduleKey: "tag", key: "tags", labelKey: "name" }]) + expect(items[0].tags_label).toBeUndefined() + }) }) diff --git a/test/crudRegistry.test.ts b/test/crudRegistry.test.ts new file mode 100644 index 0000000..95e8382 --- /dev/null +++ b/test/crudRegistry.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest" +import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "../src" +import type { IFieldConfig } from "../src" + +describe("crudRegistry 自动登记", () => { + it("ShrimpTableModule 构造时自动把 dataSource 登记到 root.crudRegistry", () => { + const shrimp = new Shrimp() + const crud = new MemoryShrimpCrud() + new ShrimpTableModule(shrimp, "tag", crud, [], "标签") + expect(shrimp.crudRegistry.get("tag")).toBe(crud) + }) + + it("通过 ShrimpTableModule.list 走 relation populate,只需声明字段无需手动 wire", async () => { + const shrimp = new Shrimp() + + const tagCrud = new MemoryShrimpCrud<{ _id: string; name: string }>() + new ShrimpTableModule(shrimp, "tag", tagCrud, [ + { key: "name", label: "名称", type: ShrimpFieldTypes.String }, + ], "标签").initCrud() + const t1 = await tagCrud.add({ name: "热门" } as any) + const t2 = await tagCrud.add({ name: "促销" } as any) + + const productCrud = new MemoryShrimpCrud<{ _id: string; code: string; tagIds: string[] }>() + const productFields: IFieldConfig[] = [ + { key: "code", label: "编码", type: ShrimpFieldTypes.String }, + { + key: "tagIds", label: "标签", type: ShrimpFieldTypes.Relation, + relation: { moduleKey: "tag", labelKey: "name" }, multiple: true, + }, + ] + const productModule = new ShrimpTableModule(shrimp, "product", productCrud, productFields, "商品") + productModule.initCrud() + + await productCrud.add({ code: "P001", tagIds: [t1._id, t2._id] } as any) + + const result = await productModule.list({}) + expect((result.list[0] as any).tagIds_label).toEqual(["热门", "促销"]) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 4210282..20bffec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "types": ["node"] }, "include": ["src/**/*", "test/**/*", "examples/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "examples/**/web/**"] }