Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ api/ platform stack: erxes-api-shared startPlugin + tenant Mongoose
or NestJS code-first federation (Node.js)
ui/ React 18 Rspack Module Federation remote, prefixed Tailwind CSS —
overview/settings pages plus a notification inbox widget
GraphQL types generated from the printed API schema (graphql-codegen)
Dockerfile API-only image for the chosen package manager
README.md, AGENTS.md, docs/erxes-integration.md
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@erxes/create-plugin",
"version": "0.1.1",
"version": "0.2.0",
"description": "Scaffold a standalone erxes plugin repository (API + React UI remote)",
"license": "MIT",
"repository": {
Expand Down
14 changes: 12 additions & 2 deletions src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,18 @@ export const runtimesFor = (o: Pick<ProjectOptions, "backend">): BackendStack["r

const LINT_DEPENDENCIES = { oxfmt: "^0.71.0", oxlint: "^1.86.0" };

/** Runs a script at the workspace root (yarn drops the extra `run`). */
const rootRun = (pm: PackageManager, script: string) =>
pm === "yarn" ? `yarn ${script}` : `${pm} run ${script}`;

export const createRootPackageJson = (o: ProjectOptions) => {
const { packageManager: pm, names } = o;
const api = `${names.name}_api`;
const ui = `${names.name}_ui`;
const apiScripts = runtimesFor(o)[runtimeFor(pm)]?.scripts ?? {};
const codegen = rootRun(pm, "codegen");
const build = [
codegen,
...("build" in apiScripts ? [workspaceRun(pm, api, "build")] : []),
workspaceRun(pm, ui, "build"),
];
Expand All @@ -77,9 +83,13 @@ export const createRootPackageJson = (o: ProjectOptions) => {
...(pm === "pnpm" ? {} : { workspaces: ["api", "ui"] }),
scripts: {
"dev:api": workspaceRun(pm, api, "dev"),
"dev:ui": workspaceRun(pm, ui, "dev"),
"dev:ui": `${codegen} && ${workspaceRun(pm, ui, "dev")}`,
codegen: [
workspaceRun(pm, api, "schema:print"),
workspaceRun(pm, ui, "codegen"),
].join(" && "),
build: build.join(" && "),
check: [workspaceRun(pm, api, "check"), workspaceRun(pm, ui, "check")].join(" && "),
check: [codegen, workspaceRun(pm, api, "check"), workspaceRun(pm, ui, "check")].join(" && "),
lint: "oxlint",
fmt: "oxfmt",
},
Expand Down
7 changes: 7 additions & 0 deletions src/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const DOTFILES: Record<string, string> = {
_gitignore: ".gitignore",
_dockerignore: ".dockerignore",
"_env.example": ".env.example",
"_oxlintrc.json": ".oxlintrc.json",
};

/** The erxes shared libraries on npm, installed under the names erxes code imports. */
Expand Down Expand Up @@ -73,6 +74,12 @@ const tokensFor = ({
? "api/src/status/status.resolver.ts"
: "api/src/graphql/schema.ts",
],
[
"__schemaPrint__",
backend === "nestjs"
? "`api/src/print-schema.ts` builds the schema from `RESOLVERS` in `api/src/resolvers.ts`; register every resolver class there (AppModule reads the same list)"
: "`api/src/print-schema.ts` prints `typeDefs` from `api/src/graphql/schema.ts` to `api/generated/schema.graphql`",
],
);

return tokens;
Expand Down
13 changes: 8 additions & 5 deletions src/stacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const NODE_SETUP: RuntimeSetup = {
build: "tsc -p tsconfig.build.json",
start: "node --env-file-if-exists=../.env dist/main.js",
check: "tsc --noEmit",
"schema:print": "tsx src/print-schema.ts",
},
devDependencies: { "@types/node": "^22.20.4", tsx: "^4.23.15" },
types: ["node"],
Expand All @@ -53,6 +54,7 @@ const BUN_SETUP: RuntimeSetup = {
dev: "bun --env-file=../.env --watch src/main.ts",
start: "bun --env-file=../.env src/main.ts",
check: "tsc --noEmit",
"schema:print": "bun src/print-schema.ts",
},
devDependencies: { "@types/bun": "^1.4.2" },
types: ["bun"],
Expand All @@ -63,7 +65,7 @@ export const BACKENDS = {
integration: "platform",
label: "Express via erxes-api-shared",
hint: "erxes-api-shared startPlugin + tenant-scoped Mongoose models (Node.js)",
templates: ["api/commonjs", "api/platform"],
templates: ["api/commonjs", "api/platform", "api/schema-print"],
runtimes: {
node: NODE_SETUP,
},
Expand All @@ -72,7 +74,7 @@ export const BACKENDS = {
integration: "standalone",
label: "Express",
hint: "Express 5 + Apollo Server federated subgraph",
templates: ["api/standalone", "api/standalone-schema", "api/express"],
templates: ["api/standalone", "api/standalone-schema", "api/express", "api/schema-print"],
runtimes: {
node: NODE_SETUP,
bun: BUN_SETUP,
Expand All @@ -82,7 +84,7 @@ export const BACKENDS = {
integration: "standalone",
label: "Fastify",
hint: "Fastify + Apollo Server federated subgraph",
templates: ["api/standalone", "api/standalone-schema", "api/fastify"],
templates: ["api/standalone", "api/standalone-schema", "api/fastify", "api/schema-print"],
runtimes: {
node: NODE_SETUP,
},
Expand All @@ -91,7 +93,7 @@ export const BACKENDS = {
integration: "standalone",
label: "Hono",
hint: "Hono + GraphQL Yoga federated subgraph (Node.js or Bun)",
templates: ["api/standalone", "api/standalone-schema", "api/hono"],
templates: ["api/standalone", "api/standalone-schema", "api/hono", "api/schema-print"],
runtimes: {
node: NODE_SETUP,
bun: BUN_SETUP,
Expand All @@ -101,7 +103,7 @@ export const BACKENDS = {
integration: "standalone",
label: "Elysia",
hint: "Elysia + GraphQL Yoga federated subgraph (Bun)",
templates: ["api/standalone", "api/standalone-schema", "api/elysia"],
templates: ["api/standalone", "api/standalone-schema", "api/elysia", "api/schema-print"],
runtimes: {
bun: BUN_SETUP,
},
Expand All @@ -118,6 +120,7 @@ export const BACKENDS = {
build: "nest build",
start: "node --env-file-if-exists=../.env dist/main.js",
check: "tsc --noEmit",
"schema:print": "nest build && node dist/print-schema.js",
},
devDependencies: { "@nestjs/cli": "^12.0.8", "@types/node": "^22.20.4" },
types: ["node"],
Expand Down
4 changes: 2 additions & 2 deletions templates/api/nestjs/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { GraphQLModule } from "@nestjs/graphql";
import { manifest } from "./config.ts";
import { createContext, type HeaderSource } from "./context.ts";
import { HealthController } from "./health.controller.ts";
import { StatusResolver } from "./status/status.resolver.ts";
import { RESOLVERS } from "./resolvers.ts";

@Module({
imports: [
Expand All @@ -17,6 +17,6 @@ import { StatusResolver } from "./status/status.resolver.ts";
}),
],
controllers: [HealthController],
providers: [StatusResolver],
providers: RESOLVERS,
})
export class AppModule {}
19 changes: 19 additions & 0 deletions templates/api/nestjs/src/print-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { GraphQLSchemaBuilderModule, GraphQLSchemaFactory } from "@nestjs/graphql";
import { printSchema } from "graphql";
import { mkdirSync, writeFileSync } from "node:fs";
import { RESOLVERS } from "./resolvers.ts";

// Builds the schema from the resolver classes without starting the HTTP
// server or joining the gateway; AppModule registers the same RESOLVERS list.
const main = async () => {
const app = await NestFactory.create(GraphQLSchemaBuilderModule, { logger: false });
await app.init();
const schema = await app.get(GraphQLSchemaFactory).create(RESOLVERS);
mkdirSync("generated", { recursive: true });
writeFileSync("generated/schema.graphql", printSchema(schema));
await app.close();
};

main().catch((error) => { console.error(error); process.exit(1); });
5 changes: 5 additions & 0 deletions templates/api/nestjs/src/resolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { StatusResolver } from "./status/status.resolver.ts";

// AppModule and print-schema.ts both read this list; register every new
// resolver class here so the printed SDL matches the running API.
export const RESOLVERS = [StatusResolver];
1 change: 1 addition & 0 deletions templates/api/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"zod": "^4.6.5"
},
"devDependencies": {
"@apollo/subgraph": "^2.15.1",
"@types/node": "^22.18.0",
"typescript": "^5.9.3"
}
Expand Down
12 changes: 12 additions & 0 deletions templates/api/schema-print/src/print-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { buildSubgraphSchema } from "@apollo/subgraph";
import { printSchema } from "graphql";
import { mkdirSync, writeFileSync } from "node:fs";
import { typeDefs } from "./graphql/schema.ts";

// Prints the subgraph SDL that ui/ codegen validates its documents against.
// Runs from api/ as a package script; needs no Redis, Mongo or running server.
mkdirSync("generated", { recursive: true });
writeFileSync(
"generated/schema.graphql",
printSchema(buildSubgraphSchema([{ typeDefs }])),
);
11 changes: 11 additions & 0 deletions templates/base/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ is the single source of the plugin's identity and ports.

- `__pm__ install`
- `__pmRun__ dev:api` / `__pmRun__ dev:ui`
- `__pmRun__ codegen`
- `__pmRun__ check`, `__pmRun__ lint`, `__pmRun__ build`, `__pmRun__ fmt`
- `docker build --build-arg UI_ENTRY_URL=<remoteEntry.js URL> -t <image> .`

Expand All @@ -35,6 +36,7 @@ __apiContracts__
`ui/src/widgets/NotificationWidget.tsx` exports `NotificationWidget` for the
`./notificationWidget` inbox expose. Keep the expose names in
`ui/rspack.config.ts` in sync with `erxes.json`.
- __schemaPrint__.
- `erxes-ui`, `ui-modules` and `erxes-api-shared` are the npm packages
`@erxes/ui`, `@erxes/ui-modules` and `@erxes/api-shared`, installed under
those import names with npm aliases; keep them on the versions your erxes
Expand All @@ -59,3 +61,12 @@ __apiContracts__
security boundary.
- Every query has loading, error and empty states; refetch or update the
Apollo cache after each mutation.
- GraphQL documents are `gql()` from `~/gql` — generated into `ui/src/gql/`
by `codegen` from `api/generated/schema.graphql` — written as a function
call on a static string with no `${}` interpolation.
- Apollo hooks infer their types from the document: no `useQuery<T>` or
`useMutation<T>` generics and no handwritten response interfaces; derive
types from the generated `*Query` types.
- `api/generated` and `ui/src/gql` are gitignored; `check`, `build` and
`dev:ui` run `codegen` first. Run `__pmRun__ codegen` after changing a
document or the schema.
20 changes: 12 additions & 8 deletions templates/base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ __pmRun__ dev:api # registers with the erxes gateway through Redis
__pmRun__ dev:ui # serves http://localhost:__uiPort__/remoteEntry.js
```

`check`, `build` and `dev:ui` run `codegen` first, so the generated GraphQL
types always match the printed API schema.

In the erxes checkout `.env`, add the plugin and restart the gateway and
core-api so they pick it up:

Expand All @@ -44,14 +47,15 @@ curl localhost:__apiPort__/graphql -H 'content-type: application/json' \

## Scripts

| Script | What it does |
| ------------------- | ------------------------------ |
| `__pmRun__ dev:api` | API in watch mode |
| `__pmRun__ dev:ui` | UI remote dev server |
| `__pmRun__ build` | Production build of every part |
| `__pmRun__ check` | Type-check every part |
| `__pmRun__ lint` | oxlint |
| `__pmRun__ fmt` | oxfmt |
| Script | What it does |
| ------------------- | ----------------------------------------------- |
| `__pmRun__ dev:api` | API in watch mode |
| `__pmRun__ dev:ui` | UI remote dev server |
| `__pmRun__ codegen` | Print the API schema, generate UI GraphQL types |
| `__pmRun__ build` | Production build of every part |
| `__pmRun__ check` | Type-check every part |
| `__pmRun__ lint` | oxlint |
| `__pmRun__ fmt` | oxfmt |

## Deploy

Expand Down
2 changes: 2 additions & 0 deletions templates/base/_dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules
**/node_modules
**/dist
**/generated
ui/src/gql
**/.env
.git
2 changes: 2 additions & 0 deletions templates/base/_gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
dist
api/generated
ui/src/gql
.env
14 changes: 14 additions & 0 deletions templates/base/_oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"ignorePatterns": ["ui/src/gql", "api/generated"],
"overrides": [
{
"files": ["ui/**/*.ts", "ui/**/*.tsx"],
"rules": {
"no-restricted-imports": ["error", { "paths": [
{ "name": "graphql-tag", "message": "Use `import { gql } from \"~/gql\"` so codegen picks the document up." },
{ "name": "@apollo/client", "importNames": ["gql"], "message": "Use `import { gql } from \"~/gql\"` so codegen picks the document up." }
] }]
}
}
]
}
11 changes: 11 additions & 0 deletions templates/base/docs/erxes-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ Notes:
compare an aliased peer range. The peer is still linked to the `erxes-ui`
this repo installs, so the warning is harmless.

## GraphQL types

`__pmRun__ codegen` prints the API's subgraph SDL to
`api/generated/schema.graphql`, then `ui/codegen.ts` (graphql-codegen client
preset) validates every `gql()` document under `ui/src` against it and
generates typed documents into `ui/src/gql/` (gitignored, like
`api/generated`). Import `gql` from `~/gql` and let Apollo hooks infer their
types — drift between the SDL and the UI fails `check` instead of surfacing
as a runtime Apollo error. The same pattern backs erxes's own `operation`
plugin.

## Styling

core-ui ships Tailwind preflight and unprefixed utilities for its own code.
Expand Down
25 changes: 25 additions & 0 deletions templates/ui/react/codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { CodegenConfig } from "@graphql-codegen/cli";

// Validates every document in src/ against the SDL the API printed to
// api/generated/schema.graphql and generates the result/variable types into
// src/gql/ (gitignored; `codegen` runs before check/build/dev).
const config: CodegenConfig = {
schema: "../api/generated/schema.graphql",
documents: ["src/**/*.{ts,tsx}", "!src/gql/**"],
generates: {
"src/gql/": {
preset: "client",
presetConfig: { fragmentMasking: false, gqlTagName: "gql" },
config: {
scalars: {
Date: { input: "string | Date", output: "string" },
JSON: "unknown",
},
enumsAsTypes: true,
useTypeImports: true,
},
},
},
};

export default config;
7 changes: 6 additions & 1 deletion templates/ui/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@
"scripts": {
"dev": "rspack serve",
"build": "rspack build",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"codegen": "graphql-codegen --config codegen.ts"
},
"dependencies": {
"@tabler/icons-react": "^3.48.0"
},
"devDependencies": {
"@apollo/client": "^3.14.1",
"@graphql-codegen/cli": "^7.4.3",
"@graphql-codegen/client-preset": "^6.2.0",
"@graphql-typed-document-node/core": "^3.2.0",
"@module-federation/enhanced": "^2.9.2",
"@rspack/cli": "^2.2.8",
"@rspack/core": "^2.2.8",
"@rspack/dev-server": "^2.2.1",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^22.20.4",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"css-loader": "^7.1.5",
Expand Down
6 changes: 5 additions & 1 deletion templates/ui/react/rspack.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
import { defineConfig } from "@rspack/cli";
import { fileURLToPath } from "node:url";
import manifest from "../erxes.json" with { type: "json" };

const { name, remote, port } = manifest.ui;
Expand All @@ -22,7 +23,10 @@ const hostShared = [
export default defineConfig({
entry: {},
output: { publicPath: "auto", uniqueName: remote, clean: true },
resolve: { extensions: [".tsx", ".ts", ".js"] },
resolve: {
extensions: [".tsx", ".ts", ".js"],
alias: { "~": fileURLToPath(new URL("./src", import.meta.url)) },
},
module: {
rules: [
{
Expand Down
Loading
Loading