@sparkerp/plugin-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/bundle/blocks.json +12229 -0
  4. package/bundle/catalog.json +32475 -0
  5. package/bundle/docs/applications/hcm/employee-profile.md +144 -0
  6. package/bundle/docs/concepts/artifact-types.md +80 -0
  7. package/bundle/docs/concepts/glossary.md +124 -0
  8. package/bundle/docs/concepts/overview.md +88 -0
  9. package/bundle/docs/concepts/page-data-flow.md +84 -0
  10. package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
  11. package/bundle/docs/concepts/sdk-modes.md +71 -0
  12. package/bundle/docs/docs.json +109 -0
  13. package/bundle/docs/guides/add-a-cadence-job.md +134 -0
  14. package/bundle/docs/guides/add-a-compliance-job.md +139 -0
  15. package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
  16. package/bundle/docs/guides/add-a-custom-block.md +225 -0
  17. package/bundle/docs/guides/add-a-data-provider.md +216 -0
  18. package/bundle/docs/guides/add-a-kpi.md +154 -0
  19. package/bundle/docs/guides/add-a-reminder-job.md +181 -0
  20. package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
  21. package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
  22. package/bundle/docs/guides/add-an-entity.md +170 -0
  23. package/bundle/docs/guides/add-business-rules.md +147 -0
  24. package/bundle/docs/guides/add-i18n.md +121 -0
  25. package/bundle/docs/guides/add-menus.md +149 -0
  26. package/bundle/docs/guides/build-a-code-plugin.md +141 -0
  27. package/bundle/docs/guides/build-a-page.md +213 -0
  28. package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
  29. package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
  30. package/bundle/docs/guides/create-a-plugin.md +172 -0
  31. package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
  32. package/bundle/docs/guides/index.md +53 -0
  33. package/bundle/docs/guides/publish-and-upgrade.md +152 -0
  34. package/bundle/docs/guides/responsive-plugin.md +117 -0
  35. package/bundle/docs/guides/seed-data-on-install.md +127 -0
  36. package/bundle/docs/guides/set-up-the-sdk.md +120 -0
  37. package/bundle/docs/guides/use-the-mcp-server.md +104 -0
  38. package/bundle/docs/guides/validate-and-test.md +119 -0
  39. package/bundle/docs/guides/wire-a-pages-data.md +153 -0
  40. package/bundle/docs/quickstart/index.md +118 -0
  41. package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
  42. package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
  43. package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
  44. package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
  45. package/bundle/docs/recipes/index.md +40 -0
  46. package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
  47. package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
  48. package/bundle/docs/recipes/master-detail-page.md +84 -0
  49. package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
  50. package/bundle/docs/recipes/per-row-document-generator.md +65 -0
  51. package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
  52. package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
  53. package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
  54. package/bundle/docs/reference/action.md +18 -0
  55. package/bundle/docs/reference/block-definition.md +35 -0
  56. package/bundle/docs/reference/block-instance.md +33 -0
  57. package/bundle/docs/reference/composite-pack-manifest.md +33 -0
  58. package/bundle/docs/reference/connector-definition.md +36 -0
  59. package/bundle/docs/reference/data-service-definition.md +22 -0
  60. package/bundle/docs/reference/data-view-definition.md +26 -0
  61. package/bundle/docs/reference/entity-aggregation-config.md +38 -0
  62. package/bundle/docs/reference/entity-cadence-config.md +33 -0
  63. package/bundle/docs/reference/entity-compliance-config.md +37 -0
  64. package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
  65. package/bundle/docs/reference/entity-definition.md +18 -0
  66. package/bundle/docs/reference/entity-document-generator-config.md +35 -0
  67. package/bundle/docs/reference/entity-rule-definition.md +29 -0
  68. package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
  69. package/bundle/docs/reference/form.md +35 -0
  70. package/bundle/docs/reference/index.md +37 -0
  71. package/bundle/docs/reference/menu.md +26 -0
  72. package/bundle/docs/reference/mobile-nav.md +22 -0
  73. package/bundle/docs/reference/page-template.md +29 -0
  74. package/bundle/docs/reference/page.md +44 -0
  75. package/bundle/docs/reference/plugin-manifest.md +54 -0
  76. package/bundle/docs/reference/plugin-seed-data.md +21 -0
  77. package/bundle/docs/reference/print-template-definition.md +22 -0
  78. package/bundle/docs/reference/provider-definition.md +22 -0
  79. package/bundle/docs/reference/report-definition.md +22 -0
  80. package/bundle/docs/reference/rule.md +30 -0
  81. package/bundle/docs/reference/workflow-definition.md +28 -0
  82. package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
  83. package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
  84. package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
  85. package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
  86. package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
  87. package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
  88. package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
  89. package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
  90. package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
  91. package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
  92. package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
  93. package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
  94. package/bundle/docs/tutorial/index.md +72 -0
  95. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
  96. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
  97. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
  98. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
  99. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
  100. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
  101. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
  102. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
  103. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
  104. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
  105. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
  106. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
  107. package/bundle/examples/example-patterns.mjs +94 -0
  108. package/bundle/manifest.json +14 -0
  109. package/bundle/schemas/action.schema.json +102 -0
  110. package/bundle/schemas/block-definition.schema.json +222 -0
  111. package/bundle/schemas/block-instance.schema.json +139 -0
  112. package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
  113. package/bundle/schemas/connector-definition.schema.json +29 -0
  114. package/bundle/schemas/data-service-definition.schema.json +64 -0
  115. package/bundle/schemas/data-view-definition.schema.json +148 -0
  116. package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
  117. package/bundle/schemas/entity-cadence-config.schema.json +27 -0
  118. package/bundle/schemas/entity-compliance-config.schema.json +31 -0
  119. package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
  120. package/bundle/schemas/entity-definition.schema.json +126 -0
  121. package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
  122. package/bundle/schemas/entity-rule-definition.schema.json +75 -0
  123. package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
  124. package/bundle/schemas/form.schema.json +281 -0
  125. package/bundle/schemas/menu.schema.json +106 -0
  126. package/bundle/schemas/mobile-nav.schema.json +53 -0
  127. package/bundle/schemas/page-template.schema.json +46 -0
  128. package/bundle/schemas/page.schema.json +292 -0
  129. package/bundle/schemas/plugin-manifest.schema.json +139 -0
  130. package/bundle/schemas/plugin-seed-data.schema.json +24 -0
  131. package/bundle/schemas/print-template-definition.schema.json +90 -0
  132. package/bundle/schemas/provider-definition.schema.json +27 -0
  133. package/bundle/schemas/report-definition.schema.json +96 -0
  134. package/bundle/schemas/rule.schema.json +82 -0
  135. package/bundle/schemas/workflow-definition.schema.json +42 -0
  136. package/bundle/validators/block-engine.mjs +23222 -0
  137. package/bundle/validators/page-engine.mjs +21645 -0
  138. package/erp-cli/authoring-root.mjs +344 -0
  139. package/erp-cli/erp.mjs +4065 -0
  140. package/erp-cli/example-patterns.mjs +94 -0
  141. package/erp-mcp-server/index.mjs +818 -0
  142. package/package.json +27 -0
@@ -0,0 +1,141 @@
1
+ ---
2
+ title: Build a plugin with custom React + Java code
3
+ audience: tenant
4
+ ---
5
+
6
+ # Build a plugin with custom React + Java code
7
+
8
+ ## What you're doing
9
+
10
+ Every other guide in these docs builds a plugin out of JSON: entities, pages,
11
+ data services, workflows. That covers the large majority of real screens. This
12
+ guide is for the remaining case — a screen that needs **real component state,
13
+ hooks, or business logic the block engine can't express**, backed by **real
14
+ server-side logic the Entity / Query / Rule / Workflow / Job engines can't
15
+ express either**.
16
+
17
+ You'll use two escape hatches together, both real and already shipped:
18
+
19
+ - **The custom-component escape hatch** (frontend) — a genuinely arbitrary
20
+ React component, mounted through `@erp/block-engine`'s `registerExternal` +
21
+ `@erp/block-adapter-mui`'s `registerCustomRenderer`, reading data through
22
+ `@erp/data`'s hooks or plain `fetch`.
23
+ - **A Java `Plugin` extension** (backend) — a real class implementing one of
24
+ `engine-plugin-api`'s extension-point interfaces (`RestContribution`,
25
+ `DashboardWidgetContribution`, and others — see
26
+ [`erp platform catalog`](./use-the-mcp-server.md)), loaded in-process into
27
+ the shared ERP via PF4J.
28
+
29
+ ## When to reach for this (and when not to)
30
+
31
+ Try these first, in order — see `create-a-plugin.md`'s own "Java: only if you
32
+ need it" section:
33
+
34
+ 1. **JSON composition** — entities, pages, data services/views, workflows,
35
+ rules. No code at all. Covers most screens.
36
+ 2. **A scheduled job config row** — no Java, see the
37
+ [job guides](./index.md#scheduled-jobs-no-java).
38
+ 3. **The compile-to-JSON Code Plugin path** — `@erp/ui` + `definePlugin()`,
39
+ for declarative composition of existing blocks with no runtime hooks/state.
40
+ 4. **The custom-component escape hatch alone** (frontend only) — real React,
41
+ reading data through the generic query engine (`useERPQuery`). No Java —
42
+ see [Add a custom block](./add-a-custom-block.md) if this is all you need.
43
+ 5. **This guide** — real React AND real Java, when the data or logic your
44
+ component needs isn't something the generic engines can compute at all
45
+ (a bespoke server-side calculation, a call to a library or resource only
46
+ your Java code should touch).
47
+
48
+ ## Level 4 (this guide) vs Level 5 (a tenant extension service)
49
+
50
+ Both let you write real, unrestricted code. They differ in **where it runs**
51
+ and **what it's allowed to touch**:
52
+
53
+ | | **Level 4 — a Java plugin (this guide)** | **Level 5 — a [tenant extension service](./build-a-tenant-extension-service.md)** |
54
+ | --- | --- | --- |
55
+ | Where your code runs | In-process, loaded via PF4J into the SAME JVM as the ERP core and every other plugin | Your own separate process/container, called over plain HTTP |
56
+ | `scope` | `global` — an ERP-vendor/marketplace-style plugin, installed once, available to any tenant that installs it | `tenant` — isolated to the one tenant that owns it |
57
+ | What it can do | Contribute a real extension point (`RestContribution`, `DashboardWidgetContribution`, `ScheduledJobContribution`, ...) — genuinely part of the platform's own request handling | Respond to ONE wire contract (`POST /extension-api/{extensionPointCode}`) the core calls with a timeout + circuit breaker |
58
+ | Trust bar | Vetted/loaded like core code — no sandbox, full JVM access, affects every tenant with it installed | The core never loads it; a slow/broken service just degrades to "not extended" |
59
+ | Use it when | You're building a plugin (JSON or code) that ANY tenant could install, same trust level as the platform's own modules | You need to run code the platform can't vet in-process — a proprietary calculation, a call to your own internal systems, dependencies that would never be approved into the shared runtime |
60
+
61
+ If you're not sure: a normal Java plugin (this guide) is the default for
62
+ `scope: global` work. Reach for an L5 extension service only when the code
63
+ genuinely must NOT run inside the shared ERP process — see that guide's own
64
+ "What you're doing" for the full reasoning.
65
+
66
+ ## The complete example
67
+
68
+ Copy the real, runnable example at
69
+ [`developer-docs/tutorial/code-plugin-example/`](../tutorial/code-plugin-example) —
70
+ don't hand-write one from scratch. It pairs:
71
+
72
+ - **Java**: `RiskScoreSummaryContribution` — a real `RestContribution` (a live
73
+ route, `GET /api/v1/plugins/risk-score-widget/summary`) that also
74
+ implements `DashboardWidgetContribution`, computing a "risk score" over the
75
+ tenant's real `organization_tag` rows with a plain `JdbcTemplate` query —
76
+ the exact same arithmetic the JSON tutorial's data lives inside, just run
77
+ in Java instead of read through the generic query engine.
78
+ - **React**: `RiskScoreSummaryPanel` — real `useState`/`useEffect`, calls that
79
+ SAME Java route with a plain `fetch` (there's no generic `@erp/data` hook
80
+ for an arbitrary plugin-owned route — only for entity reads/writes),
81
+ renders the real returned score.
82
+
83
+ ### Build and verify it yourself
84
+
85
+ ```bash
86
+ # 1. Java compiles standalone (own pom.xml, never added to the platform's
87
+ # own Maven reactor — see the example's README for what a true
88
+ # third-party build (no platform source) looks like)
89
+ mvn -f developer-docs/tutorial/code-plugin-example/java/pom.xml clean package
90
+
91
+ # 2. React type-checks and tests clean (extends the existing
92
+ # erp-code-plugin-demo workspace package)
93
+ cd frontend/packages/erp-code-plugin-demo
94
+ pnpm typecheck && pnpm test
95
+
96
+ # 3. Package the .spk — auto-refreshes spk-assembly/lib/*.jar from the
97
+ # Java build above, validates the page JSON
98
+ node tools/spark-cli/spark.js package developer-docs/tutorial/code-plugin-example/spk-assembly
99
+
100
+ # 4. Validate the manifest against the real schema
101
+ node tools/erp-cli/erp.mjs schema validate \
102
+ developer-docs/tutorial/code-plugin-example/spk-assembly/plugin.json \
103
+ --schema plugin-manifest
104
+ ```
105
+
106
+ Installing it on a live ERP and re-running the React test against the real
107
+ route is the example's own README's last step — it needs a running ERP, which
108
+ this guide doesn't assume you have yet.
109
+
110
+ ## The parts that differ from a pure-JSON plugin
111
+
112
+ - **`plugin.json`'s `mainClass`** is a real fully-qualified class name, not
113
+ `null` — see [`reference/plugin-manifest.md`](../reference/plugin-manifest.md).
114
+ - **`spk-assembly/lib/`** carries your compiled jar (and only your own
115
+ classes — `engine-plugin-api`/Spring dependencies are `provided`, resolved
116
+ from the host's own classloader at runtime, never bundled).
117
+ - **Your Java class** implements one of `engine-plugin-api`'s extension-point
118
+ interfaces (`org.pf4j.ExtensionPoint` subtypes), annotated `@Extension`.
119
+ `erp platform catalog` lists every one that exists today.
120
+ - **Your React component** is registered through `registerExternal` +
121
+ `registerCustomRenderer`, not composed from `@erp/ui` primitives — see
122
+ [Add a custom block](./add-a-custom-block.md) for that seam on its own
123
+ (no Java), and [`concepts/sdk-modes.md`](../concepts/sdk-modes.md) for the
124
+ full custom-component boundary (what IS and ISN'T sandboxed).
125
+ - **Getting the bundle onto a live tenant** is `erp plugin publish-frontend`
126
+ (uploads `dist/browser.js`, stamps `plugin.json`'s `frontendBundle`) — a
127
+ separate step from `.spk` packaging, run after install.
128
+
129
+ ## What this guide does NOT cover
130
+
131
+ - Writing your OWN new extension-point interface — `engine-plugin-api`'s
132
+ existing interfaces are what you implement; adding a new one is platform
133
+ work, not plugin work.
134
+ - Wiring a `DashboardWidgetContribution` into Studio's actual Dashboard
135
+ designer/renderer — real, shipped as an extension-point SHAPE, but the
136
+ host-side rendering wire-up is a disclosed, not-yet-built platform gap (see
137
+ that interface's own Javadoc). The example above still uses it to show the
138
+ shape; the `RestContribution` route is what's reachable end to end today.
139
+ - `scope: tenant` + Java — that's Level 5, the
140
+ [tenant extension service guide](./build-a-tenant-extension-service.md), a
141
+ different deployable entirely.
@@ -0,0 +1,213 @@
1
+ ---
2
+ title: Build a page
3
+ audience: tenant
4
+ ---
5
+
6
+ # Build a page
7
+
8
+ ## What you're doing
9
+
10
+ A **page** is one screen. It is a JSON file under `spk-assembly/metadata/page/`
11
+ describing a tree of **blocks** (widgets) arranged in rows and columns. The
12
+ platform renders it; you never write React.
13
+
14
+ The house style for a "list" screen — the one you'll build most often — is a
15
+ single column of stacked sections:
16
+
17
+ 1. a hidden **loader** block that fetches KPI counts when the page mounts
18
+ 2. a **header** row: title on the left, action buttons on the right
19
+ 3. a **KPI row**: a few `core.kpi-card` blocks
20
+ 4. a **filter row**: a search box and a status dropdown
21
+ 5. a **grid**
22
+ 6. one or more **dialogs** for create / edit / detail
23
+
24
+ ## The complete example
25
+
26
+ The tutorial's `equipment-register.json` is a full, working instance of this
27
+ pattern:
28
+ [the real file](../tutorial/example-plugin/spk-assembly/metadata/page/equipment-register.json).
29
+ It is large (every widget is spelled out), so read it alongside this guide
30
+ rather than pasting it here. Its shape on disk:
31
+
32
+ ```json
33
+ {
34
+ "name": "equipment-register",
35
+ "description": "Office Equipment tutorial - Equipment Register.",
36
+ "definition": {
37
+ "contractVersion": 1,
38
+ "id": "office-equipment.equipment-register",
39
+ "version": "1.0.0",
40
+ "publisher": "office-equipment",
41
+ "title": "office-equipment.equipment-register.title",
42
+ "usesAppShell": true,
43
+ "route": { "pattern": "/office-equipment/equipment-register", "params": [] },
44
+ "designer": {
45
+ "displayNameKey": "office-equipment.equipment-register.title",
46
+ "descriptionKey": "office-equipment.equipment-register.title",
47
+ "icon": "devices",
48
+ "category": "list",
49
+ "propertyGroups": [],
50
+ "preview": { "kind": "page" }
51
+ },
52
+ "rows": [
53
+ { "id": "row-1", "columns": [ { "id": "col-1", "items": [ /* blocks */ ] } ] }
54
+ ]
55
+ },
56
+ "metadata": { "dataSource": "oeq-equipment-provider" },
57
+ "modules": ["office-equipment"]
58
+ }
59
+ ```
60
+
61
+ ## Line by line
62
+
63
+ ### The wrapper
64
+
65
+ - **`name`** — the file's short name; unique within the plugin.
66
+ - **`definition`** — the actual page contract (`contractVersion: 1`).
67
+ - **`metadata.dataSource`** — **the single field that wires the grid to its
68
+ data.** It names a [Data Provider](./add-a-data-provider.md). It is
69
+ *page-level*, never on the grid block. Get this wrong and the grid renders zero
70
+ rows with no error. See [Wire a page's data](./wire-a-pages-data.md).
71
+ - **`modules`** — the module id(s) this page belongs to. The app shell only
72
+ lists a page in the sidenav if it's here. Use your module's `name`.
73
+
74
+ ### Inside `definition`
75
+
76
+ - **`id`** — must be `<pluginId>.<page-name>`, dotted, lowercase.
77
+ - **`title`** — an i18n key (define it in `metadata/i18n/en.json`).
78
+ - **`route.pattern`** — starts with `/`. This is an internal routing key, **not**
79
+ the browser URL. The reachable URL is
80
+ `/app/<app-slug>/<module-slug>/<pluginId>/<page-name>`.
81
+ - **`designer`** — required metadata for Studio. `category` is one of
82
+ `workspace | detail | list | dashboard | landing`. `propertyGroups: []` and
83
+ `preview: { "kind": "page" }` are fine as-is.
84
+ - **`rows`** — an array of `{ id, columns: [ { id, items: [...] } ] }`. Each
85
+ `item` is `{ id, kind: "block", block: { ... } }`.
86
+
87
+ ### A block
88
+
89
+ Every block instance looks like:
90
+
91
+ ```json
92
+ {
93
+ "contractVersion": 1,
94
+ "instanceId": "kpi-8",
95
+ "blockType": "core.kpi-card",
96
+ "blockVersion": "1.0.0",
97
+ "properties": {
98
+ "value": { "source": "binding", "binding": { "scope": "page", "key": "kpiTotal" } },
99
+ "labelKey": { "source": "static", "value": "office-equipment.equipment-register.kpi.total" },
100
+ "format": { "source": "static", "value": "number" }
101
+ }
102
+ }
103
+ ```
104
+
105
+ - Every property is `{ "source": "static", "value": ... }` (a literal) or
106
+ `{ "source": "binding", "binding": { "scope": "page", "key": "..." } }` (read
107
+ from page state).
108
+ - **`page` state** is a bag of values you set with `setValue` actions and read
109
+ with bindings. `kpiTotal`, `gridExternalFilter`, `formDialogOpen` are all just
110
+ keys you invented.
111
+
112
+ ### The mount loader
113
+
114
+ The first block is a `core.container` with `hidden: true` and a `mounted` event
115
+ that runs an **action chain** — call the KPI data service, then `setValue` each
116
+ result into page state:
117
+
118
+ ```json
119
+ {
120
+ "blockType": "core.container",
121
+ "properties": { "variant": { "source": "static", "value": "plain" },
122
+ "hidden": { "source": "static", "value": true } },
123
+ "events": {
124
+ "mounted": {
125
+ "source": "action-chain",
126
+ "actions": [
127
+ { "id": "a0", "order": 0, "type": "callApi",
128
+ "config": { "connectionRef": "self", "path": "/api/v1/data-services/oeq-equipment-kpis/execute", "httpMethod": "POST", "params": {} },
129
+ "output": "kpi" },
130
+ { "id": "a1", "order": 1, "type": "setValue",
131
+ "config": { "field": "page.kpiTotal", "value": "${kpi.results.total.value}" } }
132
+ ]
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ `${kpi.results.total.value}` is the response-shape for a **composite** data
139
+ service — see [Add a KPI](./add-a-kpi.md).
140
+
141
+ ### The grid
142
+
143
+ ```json
144
+ {
145
+ "blockType": "core.grid",
146
+ "properties": {
147
+ "columns": { "source": "static", "value": [
148
+ { "name": "asset_tag", "type": "string", "headerKey": "office-equipment.equipment-register.column.asset_tag" },
149
+ { "name": "status", "type": "string", "headerKey": "...column.status", "renderAs": "chip",
150
+ "colorMap": { "AVAILABLE": "success", "ASSIGNED": "info", "MAINTENANCE": "warning", "RETIRED": "default" } }
151
+ ] },
152
+ "pageSize": { "source": "static", "value": 25 },
153
+ "externalFilter": { "source": "binding", "binding": { "scope": "page", "key": "gridExternalFilter" } },
154
+ "refreshTrigger": { "source": "binding", "binding": { "scope": "page", "key": "gridRefreshTick" } }
155
+ }
156
+ }
157
+ ```
158
+
159
+ `core.grid` has **no** `dataSource`, `entity`, or `recordType` property — its
160
+ data comes from the page's `metadata.dataSource`. `externalFilter` binds a page
161
+ object whose keys become query filters; `refreshTrigger` re-runs the query
162
+ whenever the bound value changes (toggle it with `setValue page.gridRefreshTick
163
+ = "${!page.gridRefreshTick}"` after a save).
164
+
165
+ ## Ground yourself first
166
+
167
+ ```bash
168
+ erp blocks list # every block type
169
+ erp blocks list --type core.grid # one block's full property/event list
170
+ erp examples patterns --kind page # curated real page shapes
171
+ ```
172
+
173
+ ## How to verify it worked
174
+
175
+ ```bash
176
+ erp plugin test office-equipment/spk-assembly
177
+ ```
178
+
179
+ ```
180
+ ✓ page:equipment-register.json
181
+ 4 passed, 0 failed, 0 semantic warning(s), 3 skipped
182
+ ```
183
+
184
+ After publishing, confirm it registered:
185
+
186
+ ```bash
187
+ erp api get "/api/v1/authoring/pages?module=office-equipment"
188
+ ```
189
+
190
+ ```json
191
+ [ { "id": 11324, "name": "equipment-register", "status": "published",
192
+ "route": { "pageId": "office-equipment.equipment-register",
193
+ "pattern": "/office-equipment/equipment-register", "params": [] } } ]
194
+ ```
195
+
196
+ Then open `/app/<app>/<module>/office-equipment/equipment-register` in the ERP.
197
+
198
+ ## Common mistakes
199
+
200
+ | Symptom | Cause | Fix |
201
+ | --- | --- | --- |
202
+ | grid shows 0 rows, no error | `metadata.dataSource` missing / misspelled / not a provider name | see [Wire a page's data](./wire-a-pages-data.md); `erp plugin test` flags this as `PAGE-DS-001` |
203
+ | page not in the sidenav | `modules` array wrong or missing | set it to your module's `name` |
204
+ | KPI cards blank | wrong response path in the `setValue` | composite → `${out.results.<step>.value}`; count → `${out.value}` |
205
+ | text shows the raw i18n key | key missing from `metadata/i18n/en.json` | add it; `erp plugin test` flags `I18N-001` |
206
+ | "route" is the browser URL — it isn't | confusing `route.pattern` with the reachable URL | URL is `/app/<app>/<module>/<plugin>/<page>` |
207
+
208
+ ## What to read next
209
+
210
+ - [Wire a page's data](./wire-a-pages-data.md)
211
+ - [Add a create / edit form](./add-a-create-edit-form.md)
212
+ - [Add a KPI or aggregation](./add-a-kpi.md)
213
+ - Recipe: [KPI dashboard page](../recipes/kpi-dashboard-page.md), [master-detail page](../recipes/master-detail-page.md)
@@ -0,0 +1,201 @@
1
+ ---
2
+ title: Build a Service-mode plugin in Python or Node.js
3
+ audience: tenant
4
+ ---
5
+
6
+ # Build a Service-mode plugin in Python or Node.js
7
+
8
+ ## What you're doing
9
+
10
+ Every plugin starts life **Embedded** — Java code loaded in-process via PF4J,
11
+ sharing the ERP core's own JVM. Since 2026-08-14 a plugin can also declare
12
+ **Service mode**: its own standalone process, reached over plain HTTP, that
13
+ self-registers itself with the platform and sends a heartbeat. Until now the
14
+ only real Service-mode example was Spring Boot. As of 2026-09-14 the wire
15
+ contract is proven language-agnostic with two more real, working
16
+ implementations — the platform genuinely does not care what language your
17
+ process is written in, as long as it speaks the same three things: self-
18
+ registration, a heartbeat, and the tenant-context headers.
19
+
20
+ | Runtime | Framework | Real example in this repo | Port |
21
+ | --- | --- | --- | --- |
22
+ | `java` (default) | `spring-boot` | `backend/modules/hello-plugin-service/` | 8090 |
23
+ | `python` | `fastapi` | `backend/modules/hello-plugin-service-python/` | 8091 |
24
+ | `nodejs` | `express` | `backend/modules/hello-plugin-service-node/` | 8092 |
25
+
26
+ This is a **different mechanism** from
27
+ [Build a tenant extension service (L5)](./build-a-tenant-extension-service.md)
28
+ — don't confuse the two:
29
+
30
+ | | This guide (Service-mode plugin) | L5 extension service |
31
+ | --- | --- | --- |
32
+ | What it replaces | An entire plugin's REST surface | One narrow extension point (`EntityRecordBeforeCreate`) |
33
+ | Who calls whom | Platform proxies EVERY request for that plugin to your process | Platform calls your process only at one specific hook |
34
+ | Auth | Shared-secret self-registration token | Short-lived HMAC-signed bearer token per call |
35
+ | Scope | A full business application (HCM, CRM, or your own) | A single tenant-specific customization |
36
+
37
+ ## The fastest way: scaffold it
38
+
39
+ > The plugin ids used below (`ai-document-parser`, `whatsapp-connector`) are
40
+ > illustrative example names for this guide, chosen to show the kind of
41
+ > plugin each language suits — a Python service calling an ML model, a
42
+ > Node.js service calling a chat API. Neither ships with this platform;
43
+ > swap in your own plugin id.
44
+
45
+ Don't hand-write the self-registration/heartbeat/tenant-context wiring —
46
+ generate a real, runnable starting point:
47
+
48
+ ```bash
49
+ platform-cli service-plugin:scaffold --id ai-document-parser --runtime python --vendor "Acme Corp" --out .
50
+ # or:
51
+ platform-cli service-plugin:scaffold --id whatsapp-connector --runtime nodejs --vendor "Acme Corp" --out .
52
+ ```
53
+
54
+ This produces the same structure `hello-plugin-service-python`/
55
+ `hello-plugin-service-node` have by hand: `plugin.json`, the app source with
56
+ self-registration/heartbeat/tenant-context already wired in, a
57
+ `requirements.txt`/`package.json`, a multi-stage `Dockerfile`, and a local
58
+ dev launcher script. Both arms of this command are proven by actually running
59
+ their own scaffolded output end-to-end, not just hand-verified once.
60
+
61
+ ## The manifest
62
+
63
+ ```json
64
+ {
65
+ "id": "ai-document-parser",
66
+ "name": "AI Document Parser",
67
+ "version": "1.0.0",
68
+ "runtimeModes": ["service"],
69
+ "serviceRuntime": "python",
70
+ "serviceFramework": "fastapi"
71
+ }
72
+ ```
73
+
74
+ Python and Node.js plugins can **only** declare `runtimeModes: ["service"]`
75
+ — never `"embedded"` — nothing outside the JVM can load in-process. Installing
76
+ a manifest that sets `serviceRuntime` to `"python"`/`"nodejs"` without
77
+ `"service"` in `runtimeModes` fails install with a clear error
78
+ (`PluginCodeLifecycle#validateServiceRuntime`). `serviceRuntime`/
79
+ `serviceFramework` default to `"java"`/`"spring-boot"` when absent — every
80
+ manifest written before this feature existed keeps its exact current
81
+ behavior.
82
+
83
+ ## The wire contract, exactly
84
+
85
+ Match this regardless of language — it's the same contract
86
+ `hello-plugin-service`/`-python`/`-node` all implement:
87
+
88
+ - **Self-register on startup**: `POST {engine-api base URL}/api/v1/platform/service-registrations`
89
+ with your plugin id and this process's own reachable URL, header
90
+ `X-Plugin-Registration-Token: <token>` — same env var name in every
91
+ language, `ERP_PLUGIN_SERVICE_TOKEN`.
92
+ - **Heartbeat**: repeat the same POST on an interval (`deploymentConfig.heartbeatIntervalSeconds`,
93
+ default matches whatever your `plugin.json`'s `serviceDeployment` declares)
94
+ — a missed heartbeat past a freshness window makes the platform treat your
95
+ service as unavailable and return a clean `503`, never a hang.
96
+ - **Deregister on shutdown**: `DELETE {engine-api base URL}/api/v1/platform/service-registrations/{pluginId}`
97
+ — a graceful `SIGTERM` (container/pod termination, not a hard `kill -9`)
98
+ should trigger this.
99
+ - **Every proxied request** carries `X-Tenant-Id`/`X-Actor` (and
100
+ `Authorization` when present) — read them, never trust anything else as
101
+ the tenant identity.
102
+ - **Health endpoint**: whatever path your manifest's `serviceDeployment.healthPath`
103
+ declares (`/health` for both the Python and Node.js examples — no
104
+ FastAPI/Express equivalent of Spring's `/actuator/health` exists, so this
105
+ is just a plain declared value, not a framework convention).
106
+
107
+ ## Ground yourself first
108
+
109
+ ```bash
110
+ erp_get_schema {"name":"plugin-manifest"}
111
+ ```
112
+
113
+ ## Build, run, and verify it standalone (no ERP needed yet)
114
+
115
+ Python:
116
+
117
+ ```bash
118
+ cd backend/modules/hello-plugin-service-python
119
+ python -m venv .venv && .venv/Scripts/activate
120
+ pip install -r requirements.txt
121
+ uvicorn app.main:app --port 8091
122
+ curl localhost:8091/health
123
+ curl localhost:8091/greeting -H "X-Tenant-Id: 1" -H "X-Actor: demo"
124
+ ```
125
+
126
+ Node.js:
127
+
128
+ ```bash
129
+ cd backend/modules/hello-plugin-service-node
130
+ npm install && npm run build && npm start
131
+ curl localhost:8092/health
132
+ curl localhost:8092/greeting -H "X-Tenant-Id: 1" -H "X-Actor: demo"
133
+ ```
134
+
135
+ Then build the real container each ships:
136
+
137
+ ```bash
138
+ docker build -t ai-document-parser .
139
+ docker run -p 8091:8091 -e ERP_PLUGIN_SERVICE_TOKEN=<your token> ai-document-parser
140
+ ```
141
+
142
+ ## Test it
143
+
144
+ Both languages have a real, committed, passing test suite you can run and
145
+ extend as your own starting point — not just illustrative snippets:
146
+
147
+ ```bash
148
+ # Python — pytest + FastAPI's TestClient, no live engine-api needed
149
+ cd backend/modules/hello-plugin-service-python
150
+ pip install -r requirements.txt -r requirements-dev.txt
151
+ python -m pytest tests/ -v
152
+
153
+ # Node.js — vitest + supertest, no live engine-api needed
154
+ cd backend/modules/hello-plugin-service-node
155
+ npm install
156
+ npm test
157
+ ```
158
+
159
+ Both suites drive the real app in-process (no port bound, no engine-api
160
+ required) and cover the same two things worth testing in any Service-mode
161
+ plugin: your health endpoint responds, and your handlers correctly read
162
+ `X-Tenant-Id`/`X-Actor` from the tenant-context layer. Note Node's Express
163
+ app is deliberately split into `app.ts` (routes, importable/testable) and
164
+ `main.ts` (the actual `listen()` + registration + signal handling) — import
165
+ `createApp()` in your own tests the same way, rather than importing a file
166
+ that starts a real server as a side effect of being imported.
167
+
168
+ ## Register it against a running ERP
169
+
170
+ ```bash
171
+ start-hello-plugin-service-python.bat # or start-hello-plugin-service-node.bat
172
+ ```
173
+
174
+ Then flip the plugin to Service mode in Studio's Plugin Runtime panel — the
175
+ platform starts routing `GET /api/v1/plugins/<your-plugin-id>/**` to your
176
+ process instead of (or in addition to, if you're testing) the Java version.
177
+
178
+ ## Deploying it for real
179
+
180
+ Each example ships a parallel Kustomize tree — `deploy/kubernetes/hello-plugin-service-python/`
181
+ and `.../hello-plugin-service-node/`, mirroring the Java one's `base`/`overlays/{local,aws}`
182
+ shape with a different image/port/health-path. Copy the tree for your own
183
+ plugin id rather than trying to parameterize one shared tree across
184
+ languages — the images, ports, and health checks genuinely differ per
185
+ language and a single shared tree obscures more than it clarifies.
186
+
187
+ ## Common mistakes
188
+
189
+ | Symptom | Cause | Fix |
190
+ | --- | --- | --- |
191
+ | Install fails with a serviceRuntime error | Declared `serviceRuntime: "python"`/`"nodejs"` without `"service"` in `runtimeModes` | Embedded is always Java — add `"service"` to `runtimeModes` |
192
+ | `403`/`401` on self-registration | Token mismatch | Same `ERP_PLUGIN_SERVICE_TOKEN` value on both the ERP core and your process |
193
+ | Requests never reach your process | Plugin still on Embedded mode in Studio | Flip the Plugin Runtime toggle to Service |
194
+ | Clean `503`s from every call | No heartbeat received recently | Check your process is actually running and its heartbeat interval hasn't lapsed |
195
+ | Graceful shutdown never deregisters | Process was hard-killed (`kill -9`, or on native Windows a bare terminal `kill`) rather than sent a real `SIGTERM` | Use `docker stop`/pod termination in any real deployment — that delivers a real, trappable signal |
196
+
197
+ ## What to read next
198
+
199
+ - [Build a tenant extension service (L5)](./build-a-tenant-extension-service.md) — the narrower, single-extension-point alternative to this guide
200
+ - [Publish and upgrade a plugin](./publish-and-upgrade.md)
201
+ - `platform-cli service-plugin:scaffold --help` for every scaffold option