feat: custom widget system + remove legacy theme stack (v1.0.0-alpha.5)#128
Merged
Conversation
Address review feedback:
- add action/mutation model (useApiQuery, useApiMutation, actions[])
- replace Blob URL loader with server-path serving (/api/widget-modules/{id}/{*path})
- externalise react, react-dom, react/jsx-runtime in import-map
- switch manifest extraction to JSDoc literal block (no AST eval)
- add Vite multi-entry build pipeline section for builtins
- add first-class domain hooks (useAlerts, useServiceMonitors, useTraffic, useUptime, useGeoIp)
- delete full-SPA-replacement spa_theme system; reclaim Theme name
- chunk Plan 2 (2A/2B/2C) and Plan 3 (3A/3B/3C)
- add security-and-trust, troubleshooting, data-and-recipes docs
Sniff the magic bytes on POST /api/widget-modules and dispatch single
.js uploads to install_single_file as before, while .zip uploads now go
through a new install_collection_from_zip path. A zip bundle must contain
a top-level collection.json listing one or more entries, each pointing to
a .js or .mjs file with a valid @serverbee-widget JSDoc manifest. Widget
ids must be unique within the bundle. All widgets in a collection share
the same blob; each becomes its own row with entry_path set to the path
inside the zip.
serve_asset now mirrors the Builtin resolution behavior for zip blobs:
the requested asset path is resolved relative to the entry's folder so
that requests like /api/widget-modules/<id>/index.js find weather/index.js
inside the bundle. The single-file path is unchanged.
The endpoint response shape is documented in the utoipa annotation:
single-file installs return { id, version } and zip collections return
an array of { id, version }.
Adds five integration tests covering the happy path, missing
collection.json, duplicate ids, zip-slip in collection.json entries, and
entries missing a JSDoc manifest.
Delete the legacy custom-themes and custom-frontend pages in both languages — the underlying theme/spa-override system was removed in recent commits. Replace both with a single custom-widgets page (cn + en) that documents the new widget module system end to end: - concept overview and trust model (admin-only, same-origin) - method B: single .widget.js file (JSDoc manifest, defineWidget, build config that preserves the manifest comment, UI + API install flows) - method C: .zip collection bundle with a collection.json schema, folder layout, per-entry asset resolution rules, build/pack walkthrough, and the array response shape - sizing strategies, SDK surface, uninstall, size/SSRF/zip-slip limits Update meta.json in both languages to drop the two removed entries and insert custom-widgets under the Features section. Repoint the two remaining cross-references (status-page footer card, index card) at the new page.
Introduce a nullable module_id column so a widget row can reference an installed widget module. Legacy widgets remain unaffected (module_id stays NULL).
Add a Custom Widgets section in the picker that lists every entry from
the widget registry. Picking a module records widget_type='module' with
module_id on the dashboard editor draft, using the manifest's default
sizing. The picker callback now passes a structured selection
({type:'builtin', widgetType} | {type:'module', moduleId, manifest}) so
the editor can distinguish the two paths cleanly.
When a widget row has widget_type='module', look up its module_id in the runtime registry and render the registered component with the parsed config. Render clear placeholders for the not-installed and invalid-config cases. Includes a renderer test that registers a fake module and asserts the component output appears.
- Reject install when id collides with an existing module of a different
source_type (spec §3.5: Builtin must not be silently overwritten by an
Upload/Url upsert). Returns 409 Conflict.
- Introduce WidgetModuleError::AssetNotFound mapped to HTTP 404; reserve
InvalidAssetPath for actual traversal attempts (still 400).
- serve_asset now returns a ServedAsset { bytes, mime, version,
code_sha256 } so the route can build ETag + Cache-Control headers
without a second DB read.
- Extend mime_for with .wasm, .html, .txt, .md so module bundles can
ship companion docs/assets without falling back to octet-stream.
- Replace string-based is_private_host with full IP-resolution SSRF guard. The URL's host is resolved via tokio::net::lookup_host and every returned address must pass is_public_ip, which rejects loopback, private, CGNAT (100.64/10), link-local (incl. cloud metadata 169.254.169.254), benchmarking, documentation, multicast, and reserved-for-future-use ranges for both IPv4 and IPv6 (incl. ULA fc00::/7, fe80::/10, ::ffff:0:0/96 v4-mapped, 2001:db8::/32). - Disable HTTP redirects on the fetch client and explicitly reject 3xx responses so a public-IP DNS check cannot be bypassed by a Location pointing into the intranet. - Stream the URL body with a running total instead of buffering the full response, aborting early once MAX_MODULE_BYTES is exceeded. - Apply a tower DefaultBodyLimit::max(MAX_MODULE_BYTES + 64 KiB) to the write router so multipart uploads cannot exhaust memory before our own checks run. - Audit log every install (per widget for collection installs) and every uninstall via AuditService::log; failures are swallowed so audit never blocks the user-visible response. - Add unit tests covering is_public_ip across v4/v6 reserved ranges. Adds reqwest stream feature; futures_util was already in the tree.
- Loopback / cloud-metadata / private-CIDR install URLs are rejected. - Non-admin (member) cannot POST or DELETE on /api/widget-modules. - Uploading a single-file widget whose id matches a Builtin returns 409 Conflict (B3 spec §3.5 enforcement). - An Upload-source row can be upgraded by re-uploading with a higher version under the same source_type. Seeds a member user in start_test_server_with_db so role tests can log in without bespoke setup.
Add a public ZodSchema.introspect() that exposes kind/label/default/optional plus shape (for object) and values (for enum), so renderers no longer reach into private fields via 'as any'. Form's top-level renderer now consumes introspect() and tests cover all schema kinds.
Replace the generic text-input fallback with purpose-built widgets: - metricPath: text input + datalist seeded from runtime.getMetricPaths() (optional host hook) with a baseline of well-known paths - color: native <input type=color> - duration: text input with pattern + placeholder + title hint All three drop through cleanly when the schema kind doesn't match and are covered by new vitest cases.
… cast defineWidget now throws on duplicate action ids (caught at registration time instead of producing confusing runtime behavior where actions.find() silently picks the first match). Also remove the redundant 'as any' cast on component now that the generic propagates correctly through WidgetModule.
Object iteration order isn't a contract — without sorting, callers passing the same params in different orders produced different URLs and therefore different cache keys. Sort entries by key before building URLSearchParams.
Add a minimal semver-range matcher supporting exact, caret (^), and tilde (~) ranges. The loader now compares the host SDK_VERSION against each manifest's sdkVersion before importing the module — incompatible modules are recorded as load failures instead of being imported with potentially missing exports. Also assert SDK_VERSION stays in sync with package.json.
Add subscribeServers / subscribeTheme to WidgetRuntime so widgets can re-render on host data changes. Rewrite useServers / useServer / useMetric / useCapability / useTheme using useSyncExternalStore with memoized snapshot getters. Add NotifyOptions / ConfirmOptions / ThemeSnapshot types and optional notify / requestConfirm runtime slots so the host can wire toast and confirm dialog primitives later.
…hook Wire ActionButton through the runtime's optional requestConfirm / notify slots so the host can plug in shadcn AlertDialog and sonner toast. Falls back to window.confirm and console.* when no host hook is registered. Errors are caught and surfaced as 'error' notifications instead of bubbling. Server-side audit log emission is deferred — recorded in a TODO comment until POST /api/widget-actions/audit lands.
mountRuntimeBridge now derives serversStore / serverByIdStore from the ['servers'] React Query cache (memoized so useSyncExternalStore sees a stable reference), filters QueryCache events to the servers key for subscribeServers, mirrors the <html> dark-class theme through a MutationObserver-backed watcher, and routes notify() to sonner toasts. Also adds a runtime-bridge.test.ts that exercises cache→summary mapping, subscription dispatch, theme lookup, notify routing, plus a shim-drift test that parses public/runtime/widget-sdk.js and asserts every named export still resolves on the real SDK namespace.
The widget-sdk / react / react-dom / jsx-runtime shim files referenced by the SPA import-map are unhashed and re-emitted on every server upgrade. Without explicit no-cache, browsers held onto stale shims after upgrades, breaking dynamically-imported widget modules. Detect the /runtime/ prefix in static_files and emit Cache-Control: no-cache, no-store, must-revalidate.
When widget_type is "module", look up the registered module and use the SDK's renderConfigForm to render fields for its configSchema. Handles missing modules (disable save, show id-aware placeholder) and modules with empty schemas (show a "no configurable fields" notice). Adds tests for all three branches.
Spec §5 originally claimed widget_type would be renamed to module_id in a single migration. The actual implementation keeps both columns to let legacy V1 widgets and module-backed V2 widgets coexist on the same dashboard during the rewrite period. Document the dual-column state and the eventual cutover. custom-widgets.mdx: move useCapability to the live-hook section (it subscribes to the WS store via useSyncExternalStore, not REST). Split data hooks into "live" (server snapshot) vs "domain" (REST).
custom-widgets.mdx now documents the post-review hardening: 32 MiB zip total cap + 64-entry cap, broadened SSRF reserved ranges with explicit redirect rejection, id-collision 409, sdkVersion semver gating, audit logging, and the built-in ActionButton confirm/toast behaviour. Drop SERVERBEE_FEATURE__CUSTOM_THEMES env var and [feature].custom_themes config table from configuration.mdx — the legacy custom-theme feature was deleted in f2a1cb72 and the corresponding config field no longer exists. Drop theme_ref and "resolved theme variables" mentions from status-page.mdx — the column was dropped in m20260526_000036 and the public response no longer carries a theme block.
The dialog placeholders for "module not installed" and "no configurable fields" were finalised under the dashboard namespace (dashboard.json) in 68249ab6 / 853958e. Leave only the settings-page toast strings here.
CI runs ultracite from the repo root and lints packages/widget-sdk/, which the local apps/web invocation skips. Add biome overrides for the SDK's barrel files, runtime introspection sites, and tests so the test suite can keep using inline regex / no-op stubs / shared bitmask helpers. Inline-suppress the two legitimate src cases (window.confirm fallback in ActionButton, capability bitmask check in useCapability). Picks up the unsafe biome auto-fixes: useless React fragments around a ReactNode render, template literal for ZError prefix, and the JSDoc closing-asterisk on WidgetRuntime.notify.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@serverbee/widget-sdkpackage withdefineWidget+ JSDoc static manifest; admin install via URL or.js/.zipupload through/api/widget-modules; built-in widgets emitted by a Vite nested-build plugin and embedded via rust-embed; dashboard editor renders module widgets through a Zustand registry +useSyncExternalStoreruntime bridgespa_theme(full-SPA replacement) andcustom_theme(CSS-vars) systems plus seven preset themes, including the appearance UI, backend service/router/entity, andtheme_ref/SERVERBEE_FEATURE__CUSTOM_THEMESconfigcustom-widgets.mdx(en + zh) documents methods B (single file) and C (zip collection bundle) with the full safety surfaceImplementation
Four-plan rollout (specs + plans live under
docs/superpowers/):widget-sdkpackage (zod-lite primitives, hooks, define-widget, form renderer, actions), browser-sideWidgetRegistry+ loader + runtime bridge with import-map shims, backendwidget_moduleentity/migration/service with JSDoc extractor, list + asset routesapps/web/dist/builtin-widgets/, rust-embed register-on-boot, first builtinhello-world.widget.tsxPOST /api/widget-modules(URL + multipart),DELETE /api/widget-modules/{id}, settings page, and full deletion of legacy theme code (46 files + drop-tables migration)install_collection_from_zipwithcollection.jsoncontract + folder-relative asset resolution; bilingual docsV2 dashboard integration:
dashboard_widget.module_idcolumn, picker surfaces installed modules,WidgetRendererdispatcheswidget_type='module'throughModuleWidgetHost,WidgetConfigDialogrenders the module'sconfigSchemavia the SDK form renderer.Hardening (post-review)
Security and correctness fixes landed in a follow-up sweep:
DefaultBodyLimit::max(1 MiB + slack); URL fetch streamed with running-total accountingsource_type(e.g. overwriting a builtin) returns409 ConflictsdkVersion: semver-range gate at module load; mismatched modules go to registry failure list, not registeredmodule_idreferential check:widget_type='module'is rejected unless the referenced module is installedAssetNotFoundmaps to 404;..traversal stays 400useServers/useServer/useMetric/useCapability/useThemerewritten onuseSyncExternalStoreagainst the React Query servers cache and the host theme provider/runtime/*shims:Cache-Control: no-cacheto avoid stale shim drift after SPA upgradepublic/runtime/widget-sdk.jsand asserts every re-exported name exists on the real SDK namespaceVerification
207.241.173.217covered: method B (12 steps), method C (15 steps), V2 dashboard module_id round-trip (9 steps), post-review hardening (10 steps — SSRF exhaustive, id conflict 409, body cap 400,module_idreferential check 400, audit log entries, 404 vs 400)cargo test --workspacegreen;cargo clippy --workspace --all-targets -- -D warningscleanbun run typecheckclean;bun x ultracite check0 errors (the 9 pre-existing errors from before this branch are also cleared)apps/docstypecheck cleanTest plan
spa_themesandcustom_themetables are dropped;dashboard_widget.module_idcolumn is added.widget.jsvia Settings → Widget Modules → file input; verify list, then add it to a dashboard.zipcollection bundle withcollection.jsonlisting two widgets; verify both ids appear and each serves its own entry400 private/loopback/reserved address rejectedcom.serverbee.hello-world— must be rejected with409 ConflictDELETE /api/widget-modules/com.serverbee.hello-worldmust 400 (cannot uninstall builtin)