diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60b5e35..b52063d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,28 @@ jobs: - run: npm run typecheck - run: npm test + plugin-sdk: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web/packages/plugin-sdk + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + version: 10.4.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: web/packages/plugin-sdk/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + - run: pnpm test + - run: pnpm run build + gate: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 981a46f..4eefa9c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Console is a Go backend-for-frontend with a React UI compiled into the same bina - **OpenAI-compatible router.** Programs call `/v1` with API keys that administrators issue — with expiry and optional per-model scope. Keys are stored hashed; usage is exported as Prometheus metrics. - **Gateway discovery.** The inference entrypoint, routes and gateway key are read from the cluster (Swiss's site profile or the route ConfigMap) and followed as they change — no URLs or keys copied into configuration. - **One `helm install`.** The image and chart are published together to GHCR; point Console at Swiss and the models deployed there are in the Playground and `/v1`. +- **Optional frontend plugins.** Declare trusted frontend services and menus in configuration; load their pages through the ModelSphere Plugin SDK without rebuilding Console. See the [plugin guide](docs/development/plugins.md). - **Upgrade path to Rise Global.** Identity is wire-compatible with Rise Global (same CRDs, same token claims), so upgrading is a configuration change, not a data migration. ## Quick start diff --git a/docs/console-design.md b/docs/console-design.md index f4fdad0..a6991c8 100644 --- a/docs/console-design.md +++ b/docs/console-design.md @@ -95,7 +95,7 @@ unchanged. Moving either one moves both. ## Modules -The console is a shell plus compile-time modules. A module is a feature area +The console is a shell plus built-in and installed runtime modules. A module is a feature area (iam, inferences, playground, router, docs, later container management); the shell owns everything around it. @@ -132,9 +132,11 @@ Rules: - `validateModules` rejects duplicate ids/basePaths, shell-reserved paths and ids (`common`, `shell`: they are i18n namespaces) and pages declared twice at startup. - Text goes through `useT()` (see "i18n"); the declaration's `title` and menu labels stay Chinese. -Deliberately not built: runtime loading, micro-frontends, cross-module slots. The -earlier Cordis design (branch `docs/console-plugin-design`) is parked; revisit it -when one of those is actually needed. +Runtime plugins use `@modelsphere/plugin-sdk`, maintained in `web/packages/plugin-sdk`. Configuration declares a trusted resource service and one menu; SDK registration supplies components and internal routes. `/plugins/index.json` returns descriptors, and `/plugins//*` proxies assets without Console credentials. Registration validates identity and collisions before adding a module. Empty configuration adds no external module. + +Development uses the SDK Vite preset, same-origin WebSocket proxy and the host's React Refresh runtime. Deployment uses a built bundle served by a static-resource image. Configuration changes require a backend restart and browser refresh; compatible component edits update through Fast Refresh. This integration adds no menu/page permission policy. + +See the [authoring and deployment guide](development/plugins.md) and [design](plans/2026-11-05-global-sdk-design.md). ### i18n @@ -699,3 +701,28 @@ requests carry the same headers Global's apiserver sets. | P7 | Container management modules, moved over from Rise Global. | Each phase is independently committable and verifiable. + +## Configured frontend navigation + +The startup YAML tree selects built-ins and remote SDK plugins, their order, and nested menu folders. + +| Concern | Decision | +| --- | --- | +| Default order | Embedded `internal/config/default-plugins.yaml` | +| Compiled registry | `web/src/modules/index.ts` maps IDs to available modules; configuration determines selection/order | +| Explicit tree | Built-in and remote leaves share sibling ordering; folder nodes contain `folder`, `title`, `children` | +| Folder state | Stable folder IDs identify expandable directories; navigation opens active ancestors, including detail routes | +| Permissions | Existing page checks run before grouping; empty folders are omitted | +| Discovery | `/plugins/index.json` returns the tree and same-origin remote URLs | +| Failure | Discovery errors do not enable the registry; remote failures preserve other valid configured modules | +| Compatibility | Unmarked legacy remote lists append to default built-ins; omitted/null uses defaults; [] explicitly disables modules | + +```text +startup YAML -> validation/default resolution -> discovery tree + | +compiled registry + SDK loader -> ordered modules with folder ancestry + | +existing permissions -> visible sidebar tree -> expand/collapse +``` + +Configuration examples and lifecycle are in [the plugin guide](development/plugins.md#order-modules-and-group-menus-into-folders); the proposal is [configured navigation](plans/2026-11-06-configured-navigation-design.md). diff --git a/docs/development/plugins.md b/docs/development/plugins.md new file mode 100644 index 0000000..e6d8b10 --- /dev/null +++ b/docs/development/plugins.md @@ -0,0 +1,148 @@ +# Frontend plugins + +Console loads trusted, separately maintained frontend plugins through `@modelsphere/plugin-sdk`. + +## Prepare the SDK + +SDK source lives in `web/packages/plugin-sdk`. The package is currently built from a pinned Console checkout; it has not been published to a registry. + +```sh +export CONSOLE_SOURCE_DIR=/path/to/console +pnpm --dir "$CONSOLE_SOURCE_DIR/web/packages/plugin-sdk" install --frozen-lockfile +pnpm --dir "$CONSOLE_SOURCE_DIR/web/packages/plugin-sdk" run typecheck +pnpm --dir "$CONSOLE_SOURCE_DIR/web/packages/plugin-sdk" test +pnpm --dir "$CONSOLE_SOURCE_DIR/web/packages/plugin-sdk" run build +``` + +A consuming repository can prepare a generated local `file:` dependency from the built SDK. Keep generated package files out of Git; prepare them before dependency installation and record the Console revision with the build. Available exports are the runtime, `/build`, `/dev`, `/react-shim` and `/testing`. + +## Register pages + +```tsx +import { createPlugin } from "@modelsphere/plugin-sdk"; +import { ExamplePage, ItemPage } from "./pages"; +declare const __PLUGIN_VERSION__: string; +createPlugin({ + name: "example", + version: __PLUGIN_VERSION__, + components: { ExamplePage }, + routes: { "items/[id]": ItemPage }, +}); +``` + +The presets inject `__PLUGIN_VERSION__` from the frontend's `Chart.yaml`. Configuration selects the menu and root component. `[id]` routes become `:id`; `[...name]` becomes a catch-all. Use named imports for shared `react-router`, `@tanstack/react-query` and `@modelsphere/ui` modules. React and these imports use actual host instances. Authenticated `apiFetch` and i18n are exposed through `getBusiness().console`. Optional auth metadata creates no new menu/page policy. + +## Develop with Fast Refresh + +Create `vite.config.mjs` in the plugin frontend: + +```js +import { definePluginDevConfig } from "@modelsphere/plugin-sdk/dev"; +export default definePluginDevConfig({ + name: "example", entry: "src/runtime.tsx", + hostSource: process.env.CONSOLE_SOURCE_DIR, + hostOrigin: process.env.CONSOLE_ORIGIN, +}); +``` + +Run one Console frontend and Go backend using the normal development instructions. Preserve existing cluster/auth/backend settings and add: + +```yaml +plugins: + - id: example + url: http://127.0.0.1:4181 + development: true + entry: __modelsphere_entry.ts + menu: + title: Example + label: Example + path: /example + component: ExamplePage + icon: puzzle +``` + +Restart the existing backend. From the plugin frontend directory: + +```sh +export CONSOLE_SOURCE_DIR=/path/to/console +export CONSOLE_ORIGIN=http://127.0.0.1:5173 +pnpm exec vite --config vite.config.mjs +``` + +Refresh Console once to discover the development entry. Compatible React component edits then update automatically and preserve local state. Entry/registration changes reload the page. New shared named imports regenerate their exports while Vite runs. The SDK compiles styles against the host theme and plugin source. + +HTTP and WebSocket requests use `/plugins//` through both Console proxies. The plugin reuses the host Vite server's `/@react-refresh` runtime. This development mode requires the Console Vite frontend; a production frontend does not provide that runtime. + +## Deploy a static-resource image + +Production `tsup.config.mjs`: + +```js +import { definePluginConfig } from "@modelsphere/plugin-sdk/build"; +export default definePluginConfig({ name: "example", entry: "src/runtime.tsx", outDir: "dist" }); +``` + +Run `pnpm exec tsup --config tsup.config.mjs`. It emits `index.umd.js` and Tailwind `candidates.json`. The image build must compile CSS using those candidates and the pinned Console theme. Current stylesheet integration supports one optional frontend; arbitrary multi-plugin runtime CSS merging is outside its scope. + +Serve the built files with a static-resource image. Change the descriptor to `url: http://example-frontend:8080`, `entry: index.umd.js`, `styles: [index.css]` and omit `development`. Keep the menu declaration, restart the existing backend and refresh Console. Deployment loads the classic built bundle and rejects WebSocket upgrades. + +## Verify and recover + +| Check | Observable result | +| --- | --- | +| `GET /plugins/index.json` | Identity, same-origin URLs and menu; no upstream URL. | +| Entry and stylesheet requests | HTTP 200. | +| `Object.keys(window.__MODELSPHERE_SDK__.getPluginComponents("example"))` | Contains `ExamplePage`. | +| Menu/root and `items/` | Render under host Router/Query/UI providers. | +| Compatible component edit | Text changes with unchanged document and local state. | +| Service unavailable | Visible plugin loading error; built-ins remain usable. Restore the service and refresh. | +| Set `plugins: []`, restart and refresh | Optional menu/routes disappear; backend business records remain. | + +Configuration is read at backend startup. Open tabs retain their registry until refreshed. Plugin JavaScript runs with Console frontend privileges, so operators must select trusted services. The resource proxy strips Cookie/Authorization and upstream Set-Cookie; existing business API authentication/authorization still applies. + +`web/test/plugin-runtime.html` is a development browser test fixture using configured plugins and actual host providers. It is excluded from the production entry and does not disable application authentication. + +## Order modules and group menus into folders + +Console loads the frontend tree from `plugins` at backend startup. Siblings appear in YAML order. Built-in IDs resolve against the compiled registry; remote entries register their pages through the SDK. + +```yaml +plugins: + - folder: model-services + title: 模型与实验 + children: + - id: inferences + source: builtin + - folder: experiments + title: 实验平台 + children: + - id: canary + source: remote + url: http://canary-frontend:8080 + entry: index.umd.js + styles: [index.css] + menu: + title: 北极星 + label: 实验 + path: /canary + component: Canary + icon: flask-conical + - id: playground + source: builtin + - id: docs + source: builtin +``` + +| Declaration | Behavior | +| --- | --- | +| Omitted `plugins` or `plugins: null` | Load `internal/config/default-plugins.yaml` with the default built-ins | +| `plugins: []` | Load no modules; shell/login and overview remain | +| Explicit `source: builtin` / `source: remote` entries, or folders | Load only the listed tree | +| Legacy remote entries without `source` | Keep default built-ins, append the configured remotes | +| `folder`, `title`, `children` | Nested expandable directory; no URL, route, or permission grant | + +The available built-ins are `inferences`, `playground`, `router`, `iam` and `docs`. Built-ins keep their existing page routes, labels and permission checks. A folder disappears when none of its descendants are visible. Navigating directly to a detail page expands its ancestors; collapsing the entire sidebar leaves authorized descendant links accessible. + +Duplicate plugin IDs/folder IDs, unknown built-ins, empty folders, and mixed folder/source declarations fail configuration validation. The browser checks the complete set of configured mount paths before executing remote entries. Discovery failure shows a loading error without enabling unconfigured built-ins. A failed remote preserves other valid configured modules. + +After editing YAML, restart the existing Console backend and refresh the browser. The plugin image does not need rebuilding to change its position or folder. diff --git a/docs/installed-plugins-proposal.md b/docs/installed-plugins-proposal.md new file mode 100644 index 0000000..8d8e74c --- /dev/null +++ b/docs/installed-plugins-proposal.md @@ -0,0 +1,5 @@ +# Configured frontend plugins + +The current design is tracked in [issue #113](https://github.com/modelsphere/console/issues/113), with the [SDK architecture](plans/2026-11-05-global-sdk-design.md) and [development/deployment guide](development/plugins.md). + +Menus come from Console configuration. Pages register through `@modelsphere/plugin-sdk`. Development uses Vite source modules and Fast Refresh; deployment uses a static-resource image. diff --git a/docs/plans/2026-11-05-global-sdk-design.md b/docs/plans/2026-11-05-global-sdk-design.md new file mode 100644 index 0000000..c49caaf --- /dev/null +++ b/docs/plans/2026-11-05-global-sdk-design.md @@ -0,0 +1,126 @@ +# ModelSphere Plugin SDK architecture + +## Background + +Optional pages need independent repositories and deployments while sharing Console's React, router, query, UI and authenticated transport. ModelSphere maintains the migrated reusable Global SDK source as `@modelsphere/plugin-sdk`. + +## Goals + +- Configure one menu with SDK-registered root and detail pages. +- Develop source with Vite and React Fast Refresh, preserving compatible local state. +- Deploy built JS/CSS with a static-resource image. +- Keep business UI and release history in its owning repository. + +### Non-goals + +- Plugin manager, uploads, marketplace, dependency resolution, CRDs or lifecycle controllers. +- Global API-server delivery or new menu/page permission management. +- Every Global business module, scope or slot, or arbitrary multi-plugin stylesheet merging. + +## Architecture + +### Overview + +```text +configuration -> discovery -> SDK registration -> menu and routes + | + same-origin proxy + / \ + Vite + HMR static image + development deployment +``` + +### Design + +| Decision | Reason | +| --- | --- | +| Actual SDK source under `web/packages/plugin-sdk` | ModelSphere owns package and build maintenance while preserving the reusable contract. | +| Canonical `__MODELSPHERE_SDK__` and Global/Rise aliases | Migrated and compatible existing bundles share one host. | +| Configuration owns menu/upstream | Operator selects a trusted service; no installation database. | +| Transactional registration | Invalid identity/component/routes/collisions do not enter the registry. | +| Native development import | Wait for top-level await and complete SDK registration. | +| Host React Refresh runtime | Both Vite servers update the same host React tree. | +| Source-driven shared imports | Regenerate shim exports on edits without duplicate provider contexts. | +| Static-resource image | Deployment serves built files without Vite. | + +Configuration changes require backend restart and browser refresh. Compatible component edits use Fast Refresh; registration changes can reload the document. Discovery/loading is bounded to 15 seconds. HTTP proxy requests and response-header waits are bounded to 30 seconds; upgraded sockets remain long-lived. Resources use no-store responses. + +Trusted JavaScript runs in the Console page. Resource proxies strip browser credentials/upstream cookies and allow GET/HEAD. Only development descriptors permit WebSocket upgrades. Existing business API authentication/authorization applies; optional SDK auth metadata adds no page/menu policy. + +### Components + +| Component | Responsibility | Input/output | State owner | Dependency | +| --- | --- | --- | --- | --- | +| Configuration | Service, entry, styles, menu, development flag | YAML → validated snapshot | Operator | Service URL/component name | +| Go discovery/proxy | Descriptors, HTTP and development WebSockets | `/plugins/*` → upstream | Startup config | Vite/static service | +| SDK | Registration, types, shims, build/dev presets | Source → package/plugin outputs | SDK maintainers | Pinned toolchain | +| Browser host | Validate and commit declarations | SDK calls → Console modules | Browser document | Host React/Router/Query/UI | +| Plugin repository | Business pages/tests/image | Source + SDK → image | Plugin maintainers | Compatible host theme/SDK | +| Business backend | Existing data operations | Authenticated APIs | Existing storage | Console transport | + +### Data flow + +1. Build/deploy the static image, or run plugin Vite for development. +2. Console validates configuration and returns same-origin descriptors. +3. Browser establishes the SDK host, loads resources and collects component/route registrations. +4. Validation commits a module and the configured menu atomically. +5. Pages use shared providers and authenticated business transport. +6. Development updates cross the proxy and refresh compatible mounted components. + +## Scenarios + +### Connect a deployed frontend + +**Prerequisites:** A compatible SDK bundle and stylesheet are served by a trusted static-resource image. Preserve existing Console auth/backend settings. + +**Operation:** Configure the service, entry, styles and menu as in the [plugin guide](../development/plugins.md#deploy-a-static-resource-image), restart the existing Console and refresh. From the Console origin, request `GET /plugins/index.json` and `GET /plugins/example/index.umd.js`. + +**Visible state:** Discovery includes `example`; assets return 200; the configured menu opens its registered root page. + +**Data flow:** Configuration → Go descriptor/proxy → image files → SDK registration → Console menu/routes. + +**Completion:** The root and `items/` page render under the actual host providers; business requests retain normal authentication. + +### Edit a page with Fast Refresh + +**Prerequisites:** Console Vite is running and the SDK/plugin dependencies are prepared. Enable the development descriptor from the [guide](../development/plugins.md#develop-with-fast-refresh). + +**Operation:** From the plugin frontend directory: + +```sh +CONSOLE_SOURCE_DIR=/path/to/console CONSOLE_ORIGIN=http://127.0.0.1:5173 pnpm exec vite --config vite.config.mjs +``` + +Refresh Console once, change local page state, edit a React component and save. Also add a previously unused shared named import. + +**Visible state:** Source updates automatically; new shared exports resolve without restarting Vite. Compatible component state remains selected. + +**Data flow:** Source edit → Vite/shared-import generation → proxy WebSocket → browser HMR → host React Refresh tree. + +**Completion:** Updated content appears while `performance.timeOrigin`, a window marker and compatible local state remain unchanged. Entry/registration edits may reload the document. + +### Recover an unavailable resource service + +**Prerequisites:** A configured service is unavailable; Console's built-in modules remain accessible. + +**Operation:** Restore the existing plugin service, confirm `GET /plugins/example/index.umd.js` returns 200, then refresh Console. + +**Visible state:** A loading error is replaced by the plugin's registered page after refresh. A previously loaded tab may keep its old registry until refreshed. + +**Data flow:** Restored service → Go resource proxy → browser entry execution → validated registry. + +**Completion:** Plugin resources and pages load; retrying resource delivery creates no business records. + +### Disable a plugin + +**Prerequisites:** Operator controls the Console configuration; business records remain in the existing backend. + +**Operation:** Set `plugins: []`, restart the existing Console backend, refresh, then request `GET /plugins/index.json`. + +**Visible state:** Discovery returns `[]`; optional navigation and routes disappear. + +**Data flow:** Updated configuration → startup snapshot → discovery → new browser registry. + +**Completion:** Optional surfaces are absent and backend business data remains owned by its existing storage. + +Source origin is recorded in [UPSTREAM.md](../../web/packages/plugin-sdk/UPSTREAM.md). diff --git a/docs/plans/2026-11-06-configured-navigation-design.md b/docs/plans/2026-11-06-configured-navigation-design.md new file mode 100644 index 0000000..3f3a6ed --- /dev/null +++ b/docs/plans/2026-11-06-configured-navigation-design.md @@ -0,0 +1,51 @@ +# Configured plugins and navigation folders + +Console reads the enabled frontend tree from YAML at startup; sibling order is display order. + +```yaml +plugins: + - folder: workloads + title: 模型与实验 + children: + - id: inferences + source: builtin + - id: canary + source: remote + url: http://canary-frontend:8080 + entry: index.umd.js + styles: [index.css] + menu: + title: 北极星 + label: 实验 + path: /canary + component: Canary + - id: playground + source: builtin +``` + +| Concern | Decision | +| --- | --- | +| Empty configuration | Omitted/null uses defaults; explicit [] disables every module | +| Default installation | Embed `internal/config/default-plugins.yaml`; it declares the current built-in order | +| Sources | Built-ins resolve against the compiled module registry; remote entries use the existing SDK loader | +| Folders | `folder`, `title`, `children`; nested folders carry no resource URL, page route, or permission grant | +| Ordering | Walk the YAML tree in order; the compiled registry describes availability only | +| Selection | An explicit tree installs only listed modules; omitted built-ins do not mount routes | +| Existing remote-only config | Entries without `source` retain legacy behavior: default built-ins followed by remotes | +| Discovery | Extend `/plugins/index.json` with explicit source and folder nodes; keep upstream URLs server-side | +| Resource proxy | Resolve only remote leaves; reject assets for folders and built-ins | +| Permissions | Filter existing page permissions before grouping; hide folders with no visible descendants | +| Navigation | Expand ancestors of an active route, including hidden detail routes; collapsed sidebar keeps descendant links reachable | +| Validation | Reject duplicate folder/plugin IDs, unknown sources/built-ins, empty folders and mixed node shapes | +| Lifecycle | Restart existing backend and refresh browser after configuration changes | +| Failure | Report invalid node shapes at startup; check compiled/remote mount collisions before executing entries; discovery failure keeps only the shell; remote failures preserve other configured modules | + +```text +YAML -> validate/resolve defaults -> same-origin discovery tree + | +compiled registry + SDK registration -> ordered modules + folder ancestry + | +permissions -> visible navigation tree -> sidebar +``` + +Validation covers configuration parsing/defaults, folder discovery and remote resource delivery, mixed module ordering, nested active routes, permission pruning, duplicate labels, browser collapse/expand, and restart/refresh behavior. diff --git a/examples/console.yaml b/examples/console.yaml index 9620d60..0c84114 100644 --- a/examples/console.yaml +++ b/examples/console.yaml @@ -49,3 +49,28 @@ backends: router: secret: console/console-api-keys backend: llm + +# Frontend navigation: omit plugins (or use null) for the embedded built-in defaults. +# [] disables modules. An explicit tree selects and orders only the listed modules. +# Folders are nested directories; they do not change page permissions or routes. +# plugins: +# - folder: workloads +# title: Models and experiments +# children: +# - id: inferences +# source: builtin +# - id: example +# source: remote +# url: http://example-frontend:8080 +# entry: index.umd.js +# styles: [index.css] +# menu: +# title: Example +# label: Experiments +# path: /example +# component: Example +# icon: flask-conical +# - id: playground +# source: builtin +# - id: docs +# source: builtin diff --git a/helm/console/README.md b/helm/console/README.md index 9bb3c48..5178764 100644 --- a/helm/console/README.md +++ b/helm/console/README.md @@ -265,3 +265,9 @@ git push origin main --follow-tags ``` A newly created GHCR package is private by default. After the first publish, set it to Public in the package settings, or anonymous pulls fail. + +### Frontend menus and folders + +`plugins: null` uses Console's embedded built-in navigation. Set an explicit `plugins` tree to order built-ins and remote SDK frontends together. Folder nodes declare `folder`, `title` and nested `children`; leaf nodes declare `id` and `source: builtin` or `source: remote`. An explicit `plugins: []` disables modules. + +See [the navigation example](../../docs/development/plugins.md#order-modules-and-group-menus-into-folders). After changing Helm values, roll out the Console backend and refresh the browser. Menu position and folder changes do not require rebuilding the plugin image. diff --git a/helm/console/templates/configmap.yaml b/helm/console/templates/configmap.yaml index f6fccd8..24c9c0e 100644 --- a/helm/console/templates/configmap.yaml +++ b/helm/console/templates/configmap.yaml @@ -16,6 +16,10 @@ data: issuer: {{ .Values.auth.issuer | quote }} tokenTTL: {{ .Values.auth.tokenTTL | quote }} # jwtSecret is injected from the Secret via env, not rendered here. + {{- if ne (toJson .Values.plugins) "null" }} + plugins: + {{- toYaml .Values.plugins | nindent 6 }} + {{- end }} backends: {{- range .Values.backends }} - name: {{ .name | quote }} diff --git a/helm/console/values.yaml b/helm/console/values.yaml index 1dc46d1..5775cbc 100644 --- a/helm/console/values.yaml +++ b/helm/console/values.yaml @@ -71,6 +71,12 @@ admin: # swissd (model deployment) is not listed here: see swiss and externalSwiss. backends: [] +# Frontend navigation tree. Null uses the embedded built-in defaults; [] disables modules. +# Explicit entries use source: builtin or remote; folders have folder, title, children. +# Legacy remote entries without source retain the default built-ins. +# See docs/plans/2026-11-06-configured-navigation-design.md. +plugins: null + # swissd, the model deployment backend. Pick one: # # swiss.enabled: true install it with this release, from the swiss chart diff --git a/internal/config/chart_test.go b/internal/config/chart_test.go index 81d2298..33afcdd 100644 --- a/internal/config/chart_test.go +++ b/internal/config/chart_test.go @@ -97,6 +97,26 @@ func TestHelmAuthEnabledStillNeedsASigningKey(t *testing.T) { } } +func TestHelmConfiguredFrontendPlugin(t *testing.T) { + cfg := renderChartConfig(t, + "--set", "plugins[0].id=example", + "--set", "plugins[0].url=http://example-frontend:8080", + "--set", "plugins[0].entry=index.umd.js", + "--set", "plugins[0].styles[0]=index.css", + "--set", "plugins[0].menu.title=Example", + "--set", "plugins[0].menu.label=Open example", + "--set", "plugins[0].menu.path=/example", + "--set", "plugins[0].menu.component=Example", + ) + cfg.Server.Auth.JWTSecret = "test" + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if len(cfg.Plugins) != 1 || cfg.Plugins[0].Menu.Component != "Example" || len(cfg.Plugins[0].Styles) != 1 || cfg.Plugins[0].Styles[0] != "index.css" { + t.Fatalf("rendered plugin configuration = %+v", cfg.Plugins) + } +} + // The swiss subchart: console proxies to its Service with swissd's front-proxy // key and reads its site profile, and swissd keeps its own login on. func TestHelmSwissSubchartIsWiredIn(t *testing.T) { @@ -171,3 +191,10 @@ func TestHelmSwissTwiceIsRefused(t *testing.T) { t.Fatalf("want a refusal naming both, got err=%v\n%s", err, out) } } + +func TestHelmExplicitEmptyFrontendTree(t *testing.T) { + cfg := renderChartConfig(t, "--set-json", "plugins=[]") + if cfg.Plugins == nil || len(cfg.FrontendPlugins()) != 0 { + t.Fatalf("empty Helm frontend tree: %+v", cfg.Plugins) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a6ea990..c15c1a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { Server Server `yaml:"server,omitempty"` Backends []Backend `yaml:"backends,omitempty"` Router Router `yaml:"router,omitempty"` + Plugins []Plugin `yaml:"plugins,omitempty"` Origin string `yaml:"-"` } @@ -226,6 +227,9 @@ func (c *Config) origin() string { // Validate covers what console needs to serve. func (c *Config) Validate() error { + if err := validatePlugins(c.FrontendPlugins()); err != nil { + return fmt.Errorf("%s: %w", c.origin(), err) + } if c.Server.Auth.JWTSecret == "" && !c.Server.Auth.Disabled { return fmt.Errorf("%s: server.auth.jwtSecret is required -- it signs every token, or set server.auth.disabled to run without a login", c.origin()) } diff --git a/internal/config/default-plugins.yaml b/internal/config/default-plugins.yaml new file mode 100644 index 0000000..c4ee0c4 --- /dev/null +++ b/internal/config/default-plugins.yaml @@ -0,0 +1,10 @@ +- id: inferences + source: builtin +- id: playground + source: builtin +- id: router + source: builtin +- id: iam + source: builtin +- id: docs + source: builtin diff --git a/internal/config/navigation.go b/internal/config/navigation.go new file mode 100644 index 0000000..9d662c1 --- /dev/null +++ b/internal/config/navigation.go @@ -0,0 +1,122 @@ +package config + +import ( + _ "embed" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed default-plugins.yaml +var defaultPluginYAML []byte + +func defaultPlugins() []Plugin { + var plugins []Plugin + if err := yaml.Unmarshal(defaultPluginYAML, &plugins); err != nil { + panic(fmt.Sprintf("invalid embedded frontend defaults: %v", err)) + } + return plugins +} + +// FrontendPlugins resolves the explicit tree or the defaults for legacy remote-only configurations. +func (c *Config) FrontendPlugins() []Plugin { + if c.Plugins != nil && len(c.Plugins) == 0 { + return c.Plugins + } + for _, plugin := range c.Plugins { + if plugin.Source != "" || plugin.Folder != "" { + return c.Plugins + } + } + return append(defaultPlugins(), c.Plugins...) +} + +// RemotePlugins returns only executable remote leaves, in configured traversal order. +func (c *Config) RemotePlugins() []Plugin { + var remotes []Plugin + var visit func([]Plugin) + visit = func(nodes []Plugin) { + for _, node := range nodes { + if node.Folder != "" { + visit(node.Children) + } else if node.Source != "builtin" { + remotes = append(remotes, node) + } + } + } + visit(c.Plugins) + return remotes +} + +type navigationValidator struct { + plugins map[string]bool + folders map[string]bool + paths map[string]bool + builtins map[string]bool +} + +func validatePlugins(plugins []Plugin) error { + validator := navigationValidator{map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}} + for _, builtin := range defaultPlugins() { + validator.builtins[builtin.ID] = true + } + return validator.visit(plugins, "plugins") +} + +func (v *navigationValidator) visit(nodes []Plugin, location string) error { + for index, node := range nodes { + path := fmt.Sprintf("%s[%d]", location, index) + if err := v.node(node, path); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + return nil +} + +func (v *navigationValidator) node(node Plugin, path string) error { + if node.Folder != "" { + return v.folder(node, path) + } + if node.Title != "" || node.Children != nil { + return fmt.Errorf("title and children require a folder") + } + if v.plugins[node.ID] { + return fmt.Errorf("duplicate plugin id %q", node.ID) + } + v.plugins[node.ID] = true + switch node.Source { + case "builtin": + if !v.builtins[node.ID] { + return fmt.Errorf("unknown built-in module %q", node.ID) + } + if node.URL != "" || node.Entry != "" || len(node.Styles) != 0 || node.Development || node.Menu != (PluginMenu{}) { + return fmt.Errorf("built-in modules cannot declare remote resources or menu overrides") + } + case "", "remote": + if err := validatePlugin(node); err != nil { + return err + } + if v.paths[node.Menu.Path] { + return fmt.Errorf("duplicate menu path %q", node.Menu.Path) + } + v.paths[node.Menu.Path] = true + default: + return fmt.Errorf("unknown source %q; use builtin or remote", node.Source) + } + return nil +} + +func (v *navigationValidator) folder(node Plugin, path string) error { + if !pluginName.MatchString(node.Folder) || strings.TrimSpace(node.Title) == "" || len(node.Children) == 0 { + return fmt.Errorf("folder requires a lowercase kebab-case name, title and nonempty children") + } + if v.folders[node.Folder] { + return fmt.Errorf("duplicate folder %q", node.Folder) + } + v.folders[node.Folder] = true + if node.ID != "" || node.Source != "" || node.URL != "" || node.Entry != "" || len(node.Styles) > 0 || node.Development || node.Menu != (PluginMenu{}) { + return fmt.Errorf("folder cannot declare a plugin source, resources or a page menu") + } + return v.visit(node.Children, path+".children") +} diff --git a/internal/config/navigation_test.go b/internal/config/navigation_test.go new file mode 100644 index 0000000..3df4b17 --- /dev/null +++ b/internal/config/navigation_test.go @@ -0,0 +1,67 @@ +package config + +import ( + "strings" + "testing" +) + +func TestFrontendDefaultsAndLegacySources(t *testing.T) { + remote := Plugin{ID: "example", URL: "http://frontend:8080", Entry: "index.umd.js", Menu: PluginMenu{Title: "Example", Label: "Example", Path: "/example", Component: "Example"}} + cfg := Config{Plugins: []Plugin{remote}} + resolved := cfg.FrontendPlugins() + if len(resolved) != 6 || resolved[0].Source != "builtin" || resolved[5].ID != "example" { + t.Fatalf("legacy frontend tree: %+v", resolved) + } + cfg.Plugins[0].Source = "remote" + if len(cfg.FrontendPlugins()) != 1 { + t.Fatal("explicit remote configuration included defaults") + } +} + +func TestNestedFrontendTree(t *testing.T) { + cfg := Config{Server: Server{Auth: Auth{Disabled: true}}, Plugins: []Plugin{ + {Folder: "models", Title: "模型", Children: []Plugin{ + {ID: "inferences", Source: "builtin"}, + {Folder: "experiments", Title: "实验", Children: []Plugin{{ID: "canary", Source: "remote", URL: "http://frontend:8080", Entry: "index.umd.js", Menu: PluginMenu{Title: "Canary", Label: "实验", Path: "/canary", Component: "Canary"}}}}, + }}, + }} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if len(cfg.FrontendPlugins()) != 1 || len(cfg.RemotePlugins()) != 1 || cfg.RemotePlugins()[0].ID != "canary" { + t.Fatal("tree did not retain folder structure or remote leaves") + } +} + +func TestFrontendTreeValidation(t *testing.T) { + cases := []struct { + name string + nodes []Plugin + want string + }{ + {"unknown builtin", []Plugin{{ID: "unknown", Source: "builtin"}}, "unknown built-in"}, + {"unknown source", []Plugin{{ID: "example", Source: "other"}}, "unknown source"}, + {"empty folder", []Plugin{{Folder: "empty", Title: "Empty"}}, "nonempty"}, + {"untitled folder", []Plugin{{Folder: "empty", Children: []Plugin{{ID: "docs", Source: "builtin"}}}}, "title"}, + {"mixed folder", []Plugin{{Folder: "mixed", Title: "Mixed", URL: "http://example.com", Children: []Plugin{{ID: "docs", Source: "builtin"}}}}, "folder cannot"}, + {"children on leaf", []Plugin{{ID: "docs", Source: "builtin", Children: []Plugin{}}}, "require a folder"}, + {"builtin resources", []Plugin{{ID: "docs", Source: "builtin", URL: "http://example.com"}}, "built-in modules cannot"}, + {"duplicate nested module", []Plugin{{ID: "docs", Source: "builtin"}, {Folder: "nested", Title: "Nested", Children: []Plugin{{ID: "docs", Source: "builtin"}}}}, "duplicate plugin"}, + {"duplicate folders", []Plugin{{Folder: "same", Title: "First", Children: []Plugin{{ID: "docs", Source: "builtin"}}}, {Folder: "same", Title: "Second", Children: []Plugin{{ID: "iam", Source: "builtin"}}}}, "duplicate folder"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + cfg := Config{Server: Server{Auth: Auth{Disabled: true}}, Plugins: test.nodes} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want %q", err, test.want) + } + }) + } +} + +func TestExplicitEmptyFrontendTree(t *testing.T) { + cfg := Config{Plugins: []Plugin{}} + if got := cfg.FrontendPlugins(); got == nil || len(got) != 0 { + t.Fatalf("explicit empty tree: %+v", got) + } +} diff --git a/internal/config/plugins.go b/internal/config/plugins.go new file mode 100644 index 0000000..32d9035 --- /dev/null +++ b/internal/config/plugins.go @@ -0,0 +1,64 @@ +package config + +import ( + "fmt" + "io/fs" + "net/url" + "regexp" + "strings" +) + +// Plugin identifies a trusted frontend image service and its navigation entry. +type Plugin struct { + Source string `yaml:"source,omitempty" json:"source,omitempty"` + Folder string `yaml:"folder,omitempty" json:"folder,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Children []Plugin `yaml:"children,omitempty" json:"children,omitempty"` + Development bool `yaml:"development,omitempty" json:"development,omitempty"` + ID string `yaml:"id" json:"id"` + URL string `yaml:"url" json:"-"` + Entry string `yaml:"entry" json:"entry"` + Styles []string `yaml:"styles,omitempty" json:"styles,omitempty"` + Menu PluginMenu `yaml:"menu" json:"menu"` +} + +type PluginMenu struct { + Title string `yaml:"title" json:"title"` + Label string `yaml:"label" json:"label"` + Path string `yaml:"path" json:"path"` + Component string `yaml:"component" json:"component"` + Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` +} + +var pluginName = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) +var pluginBase = regexp.MustCompile(`^/[a-z][a-z0-9-]*$`) + +// ValidPluginAsset limits assets to relative URL paths inside the image service. +func ValidPluginAsset(asset string) bool { + return asset != "." && fs.ValidPath(asset) && !strings.ContainsAny(asset, `\?#%:`) +} + +func validatePlugin(plugin Plugin) error { + if !pluginName.MatchString(plugin.ID) { + return fmt.Errorf("id %q must be lowercase kebab-case", plugin.ID) + } + upstream, err := url.Parse(plugin.URL) + if err != nil || (upstream.Scheme != "http" && upstream.Scheme != "https") || upstream.Host == "" || upstream.User != nil || upstream.RawQuery != "" || upstream.Fragment != "" { + return fmt.Errorf("url must be an absolute http(s) service URL without credentials, query or fragment") + } + for _, asset := range append([]string{plugin.Entry}, plugin.Styles...) { + if !ValidPluginAsset(asset) { + return fmt.Errorf("asset %q must be a relative file path", asset) + } + } + menu := plugin.Menu + if menu.Title == "" || menu.Label == "" || menu.Component == "" || !pluginBase.MatchString(menu.Path) { + return fmt.Errorf("menu needs title, label, component and a path like /example") + } + for _, reserved := range []string{"/login", "/change-password", "/api", "/oauth", "/plugins", "/assets"} { + if menu.Path == reserved { + return fmt.Errorf("menu path %q is reserved", menu.Path) + } + } + return nil +} diff --git a/internal/config/plugins_test.go b/internal/config/plugins_test.go new file mode 100644 index 0000000..0277a70 --- /dev/null +++ b/internal/config/plugins_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestConfiguredFrontendPlugin(t *testing.T) { + valid := `server: + auth: + disabled: true +plugins: + - id: example + url: http://example-frontend:8080/assets + entry: index.umd.js + styles: [index.css] + menu: + title: Example + label: Example + path: /example + component: Example +` + file := filepath.Join(t.TempDir(), "console.yaml") + if err := os.WriteFile(file, []byte(valid), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(file) + if err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestPluginConfigurationRejectsInvalidSources(t *testing.T) { + valid := Plugin{ID: "example", URL: "http://frontend:8080", Entry: "index.umd.js", Menu: PluginMenu{Title: "Example", Label: "Example", Path: "/example", Component: "Example"}} + for _, test := range []struct { + name string + change func(*Plugin) + want string + }{ + {"relative URL", func(plugin *Plugin) { plugin.URL = "/frontend" }, "absolute"}, + {"credential URL", func(plugin *Plugin) { plugin.URL = "http://user:password@frontend" }, "credentials"}, + {"entry traversal", func(plugin *Plugin) { plugin.Entry = "../private.js" }, "relative"}, + {"style URL", func(plugin *Plugin) { plugin.Styles = []string{"https://unconfigured/style.css"} }, "relative"}, + {"empty component", func(plugin *Plugin) { plugin.Menu.Component = "" }, "component"}, + {"reserved mount", func(plugin *Plugin) { plugin.Menu.Path = "/plugins" }, "reserved"}, + } { + t.Run(test.name, func(t *testing.T) { + plugin := valid + test.change(&plugin) + cfg := Config{Server: Server{Auth: Auth{Disabled: true}}, Plugins: []Plugin{plugin}} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want %q", err, test.want) + } + }) + } + if err := (&Config{Plugins: []Plugin{valid, valid}}).Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("duplicate sources: %v", err) + } +} diff --git a/internal/server/navigation_test.go b/internal/server/navigation_test.go new file mode 100644 index 0000000..545e2de --- /dev/null +++ b/internal/server/navigation_test.go @@ -0,0 +1,42 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelsphere/console/internal/config" +) + +func TestNavigationFoldersAndNestedAssets(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/index.js" { + t.Errorf("upstream path: %s", r.URL.Path) + } + _, _ = w.Write([]byte("nested SDK bundle")) + })) + defer upstream.Close() + srv := pluginServer(t) + srv.cfg.Plugins = []config.Plugin{{Folder: "models", Title: "Models", Children: []config.Plugin{ + {ID: "inferences", Source: "builtin"}, + {Folder: "experiments", Title: "Experiments", Children: []config.Plugin{{ID: "canary", Source: "remote", URL: upstream.URL, Entry: "index.js", Menu: config.PluginMenu{Title: "Canary", Label: "Canary", Path: "/canary", Component: "Canary"}}}}, + }}} + index := do(srv.Handler(), "GET", "/plugins/index.json", "", "") + var nodes []pluginManifest + if err := json.Unmarshal(index.Body.Bytes(), &nodes); err != nil { + t.Fatal(err) + } + if len(nodes) != 1 || nodes[0].Folder != "models" || nodes[0].Children[0].Source != "builtin" || nodes[0].Children[1].Children[0].Entry != "/plugins/canary/index.js" || strings.Contains(index.Body.String(), upstream.URL) { + t.Fatalf("navigation tree: %s", index.Body.String()) + } + if response := do(srv.Handler(), "GET", "/plugins/canary/index.js", "", ""); response.Code != 200 || response.Body.String() != "nested SDK bundle" { + t.Fatalf("nested asset: %d %s", response.Code, response.Body.String()) + } + for _, path := range []string{"/plugins/models/index.js", "/plugins/inferences/index.js"} { + if response := do(srv.Handler(), "GET", path, "", ""); response.Code != 404 { + t.Errorf("nonremote node %s: %d", path, response.Code) + } + } +} diff --git a/internal/server/plugin_development_test.go b/internal/server/plugin_development_test.go new file mode 100644 index 0000000..44cfe92 --- /dev/null +++ b/internal/server/plugin_development_test.go @@ -0,0 +1,123 @@ +package server + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/modelsphere/console/internal/config" +) + +func TestDevelopmentPluginDiscovery(t *testing.T) { + srv := pluginServer(t) + srv.cfg.Plugins = []config.Plugin{{ID: "example", URL: "http://frontend:4181", Entry: "__modelsphere_entry.ts", Development: true}} + response := do(srv.Handler(), "GET", "/plugins/index.json", "", "") + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"development": true`) { + t.Fatalf("development discovery: %d %s", response.Code, response.Body.String()) + } +} + +func TestDeploymentPluginRejectsWebSocket(t *testing.T) { + upstreamCalled := false + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { upstreamCalled = true })) + defer upstream.Close() + srv := pluginServer(t) + srv.cfg.Plugins = []config.Plugin{{ID: "example", URL: upstream.URL, Entry: "entry.js"}} + request := httptest.NewRequest(http.MethodGet, "/plugins/example/__hmr", nil) + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "websocket") + response := httptest.NewRecorder() + srv.Handler().ServeHTTP(response, request) + if response.Code != http.StatusNotFound || upstreamCalled { + t.Fatalf("production upgrade: status=%d upstream=%v", response.Code, upstreamCalled) + } +} + +func TestDevelopmentPluginPreservesBaseAndConsoleHost(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Host != "console.example.test" || request.URL.Path != "/plugins/example/src/page.tsx" || request.URL.RawQuery != "t=123" { + t.Errorf("development request: host=%s url=%s", request.Host, request.URL) + } + writer.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + srv := pluginServer(t) + srv.cfg.Plugins = []config.Plugin{{ID: "example", URL: upstream.URL, Entry: "entry.js", Development: true}} + request := httptest.NewRequest(http.MethodGet, "http://console.example.test/plugins/example/src/page.tsx?t=123", nil) + response := httptest.NewRecorder() + srv.Handler().ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("development response: %d %s", response.Code, response.Body.String()) + } +} + +func TestDevelopmentPluginProxiesWebSocket(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(echoDevelopmentSocket)) + defer upstream.Close() + srv := pluginServer(t) + srv.cfg.Plugins = []config.Plugin{{ID: "example", URL: upstream.URL, Entry: "entry.js", Development: true}} + frontend := httptest.NewServer(srv.Handler()) + defer frontend.Close() + connection, reader := openDevelopmentSocket(t, frontend.URL) + if _, err := connection.Write([]byte("hmr-update")); err != nil { + t.Fatal(err) + } + payload := make([]byte, len("hmr-update")) + if _, err := io.ReadFull(reader, payload); err != nil { + t.Fatal(err) + } + if string(payload) != "hmr-update" { + t.Fatalf("payload: %q", payload) + } +} + +func echoDevelopmentSocket(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/plugins/example/__hmr" || request.Header.Get("Sec-WebSocket-Protocol") != "vite-hmr" || request.Header.Get("Cookie") != "" || request.Header.Get("Authorization") != "" { + http.Error(writer, "invalid development proxy headers", http.StatusBadRequest) + return + } + connection, buffered, err := http.NewResponseController(writer).Hijack() + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + defer connection.Close() + if _, err := buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); err != nil { + return + } + if err := buffered.Flush(); err != nil { + return + } + _, _ = io.Copy(connection, buffered) +} + +func openDevelopmentSocket(t *testing.T, frontendURL string) (net.Conn, *bufio.Reader) { + t.Helper() + connection, err := net.DialTimeout("tcp", strings.TrimPrefix(frontendURL, "http://"), time.Second) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = connection.Close() }) + if err := connection.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + _, err = fmt.Fprintf(connection, "GET /plugins/example/__hmr HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Protocol: vite-hmr\r\nCookie: token=must-not-leak\r\nAuthorization: Bearer must-not-leak\r\n\r\n") + if err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(connection) + response, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("upgrade: %s", response.Status) + } + return connection, reader +} diff --git a/internal/server/plugin_service.go b/internal/server/plugin_service.go new file mode 100644 index 0000000..f907c27 --- /dev/null +++ b/internal/server/plugin_service.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "github.com/modelsphere/console/internal/config" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" +) + +var pluginResourceTransport = newPluginResourceTransport() + +func newPluginResourceTransport() *http.Transport { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = 30 * time.Second + return transport +} + +func (s *Server) pluginIndex() []pluginManifest { + return pluginTree(s.cfg.FrontendPlugins()) +} + +func pluginTree(nodes []config.Plugin) []pluginManifest { + result := make([]pluginManifest, 0, len(nodes)) + for _, node := range nodes { + if node.Folder != "" { + result = append(result, pluginManifest{Folder: node.Folder, Title: node.Title, Children: pluginTree(node.Children)}) + continue + } + manifest := pluginManifest{ID: node.ID, Source: node.Source} + if node.Source != "builtin" { + prefix := "/plugins/" + node.ID + "/" + manifest.Source, manifest.Entry, manifest.Menu = "remote", prefix+node.Entry, &node.Menu + manifest.Development = node.Development + for _, style := range node.Styles { + manifest.Styles = append(manifest.Styles, prefix+style) + } + } + result = append(result, manifest) + } + return result +} + +func pluginProxyHeaders(request *httputil.ProxyRequest, development bool) { + request.Out.Header = make(http.Header) + headers := []string{"Accept", "Accept-Encoding", "Range", "If-None-Match", "If-Modified-Since"} + if development { + headers = append(headers, "Origin", "Sec-WebSocket-Key", "Sec-WebSocket-Version", "Sec-WebSocket-Protocol", "Sec-WebSocket-Extensions") + if strings.EqualFold(request.In.Header.Get("Upgrade"), "websocket") { + request.Out.Header.Set("Connection", "Upgrade") + request.Out.Header.Set("Upgrade", "websocket") + } + } + for _, header := range headers { + if value := request.In.Header.Get(header); value != "" { + request.Out.Header.Set(header, value) + } + } +} + +func (s *Server) servePluginService(writer http.ResponseWriter, request *http.Request, plugin config.Plugin, asset string) { + socket := strings.EqualFold(request.Header.Get("Upgrade"), "websocket") + if !config.ValidPluginAsset(asset) || (request.Method != http.MethodGet && request.Method != http.MethodHead) || (socket && !plugin.Development) { + http.NotFound(writer, request) + return + } + upstream, err := url.Parse(plugin.URL) + if err != nil { + s.log.Error("parse plugin service URL", "plugin", plugin.ID, "error", err) + writeError(writer, http.StatusBadGateway, "invalid plugin service") + return + } + ctx := request.Context() + if !socket { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 30*time.Second) + defer cancel() + } + proxy := s.pluginResourceProxy(plugin, upstream, asset) + proxy.ServeHTTP(writer, request.WithContext(ctx)) +} + +func (s *Server) pluginResourceProxy(plugin config.Plugin, upstream *url.URL, asset string) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Transport: pluginResourceTransport, + Rewrite: func(out *httputil.ProxyRequest) { + out.Out.URL.Path, out.Out.URL.RawPath = "/"+asset, "" + if plugin.Development { + out.Out.URL.Path = "/plugins/" + plugin.ID + "/" + asset + } + out.SetURL(upstream) + if plugin.Development { + out.Out.Host = out.In.Host + } + pluginProxyHeaders(out, plugin.Development) + }, + ModifyResponse: func(response *http.Response) error { + response.Header.Del("Set-Cookie") + response.Header.Set("Cache-Control", "no-store") + return nil + }, + ErrorHandler: func(response http.ResponseWriter, _ *http.Request, cause error) { + s.log.Error("plugin service unavailable", "plugin", plugin.ID, "error", cause) + writeError(response, http.StatusBadGateway, "plugin service unavailable") + }, + } +} diff --git a/internal/server/plugin_service_test.go b/internal/server/plugin_service_test.go new file mode 100644 index 0000000..45c78bf --- /dev/null +++ b/internal/server/plugin_service_test.go @@ -0,0 +1,70 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelsphere/console/internal/config" +) + +func TestPluginImageService(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Error("frontend image received session credentials") + } + if r.URL.Path != "/frontend/index.umd.js" { + t.Errorf("upstream path = %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Set-Cookie", "untrusted=value") + _, _ = w.Write([]byte("SDK bundle")) + })) + defer upstream.Close() + srv := pluginServer(t) + srv.cfg.Plugins = []config.Plugin{{ID: "example", Source: "remote", URL: upstream.URL + "/frontend", Entry: "index.umd.js", Menu: config.PluginMenu{Title: "Example", Label: "Example", Path: "/example", Component: "Example"}}} + index := do(srv.Handler(), "GET", "/plugins/index.json", "", "") + var descriptors []pluginManifest + if err := json.Unmarshal(index.Body.Bytes(), &descriptors); err != nil { + t.Fatal(err) + } + if len(descriptors) != 1 || descriptors[0].Entry != "/plugins/example/index.umd.js" || descriptors[0].Menu.Component != "Example" || strings.Contains(index.Body.String(), upstream.URL) { + t.Fatalf("discovery: %s", index.Body.String()) + } + request := httptest.NewRequest("GET", "/plugins/example/index.umd.js", nil) + request.Header.Set("Authorization", "Bearer test-session") + request.Header.Set("Cookie", "token=test-session") + response := httptest.NewRecorder() + srv.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Body.String() != "SDK bundle" { + t.Fatalf("asset: %d %s", response.Code, response.Body.String()) + } + if response.Header().Get("Set-Cookie") != "" { + t.Fatal("frontend image set a Console cookie") + } +} + +func TestPluginImageUnavailableAndInvalidPaths(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + t.Errorf("frontend received write method %s", request.Method) + } + w.WriteHeader(http.StatusNotFound) + })) + srv := pluginServer(t) + srv.cfg.Plugins = []config.Plugin{{ID: "example", URL: upstream.URL, Entry: "index.umd.js"}} + for _, path := range []string{"/plugins/example/../private", "/plugins/example/%2e%2e/private", "/plugins/example/missing.js"} { + if response := do(srv.Handler(), "GET", path, "", ""); response.Code != http.StatusNotFound { + t.Fatalf("%s: %d", path, response.Code) + } + } + if response := do(srv.Handler(), "POST", "/plugins/example/index.umd.js", "", ""); response.Code != http.StatusNotFound { + t.Fatalf("write method: %d", response.Code) + } + upstream.Close() + if response := do(srv.Handler(), "GET", "/plugins/example/index.umd.js", "", ""); response.Code != http.StatusBadGateway { + t.Fatalf("unavailable frontend: %d", response.Code) + } +} diff --git a/internal/server/plugins.go b/internal/server/plugins.go new file mode 100644 index 0000000..878f22b --- /dev/null +++ b/internal/server/plugins.go @@ -0,0 +1,46 @@ +package server + +import ( + "github.com/modelsphere/console/internal/config" + "net/http" + "strings" +) + +type pluginManifest struct { + Source string `json:"source,omitempty"` + Folder string `json:"folder,omitempty"` + Title string `json:"title,omitempty"` + Children []pluginManifest `json:"children,omitempty"` + ID string `json:"id,omitempty"` + Entry string `json:"entry,omitempty"` + Styles []string `json:"styles,omitempty"` + Menu *config.PluginMenu `json:"menu,omitempty"` + Development bool `json:"development,omitempty"` +} + +func (s *Server) handlePluginIndex(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + plugins := s.pluginIndex() + writeJSON(writer, http.StatusOK, plugins) +} + +func (s *Server) handlePluginAsset(writer http.ResponseWriter, request *http.Request) { + id, asset := request.PathValue("id"), request.PathValue("asset") + for _, plugin := range s.cfg.RemotePlugins() { + if plugin.ID == id { + s.servePluginService(writer, request, plugin, asset) + return + } + } + http.NotFound(writer, request) +} + +func pluginPaths(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if strings.HasPrefix(request.URL.Path, "/plugins/") && !config.ValidPluginAsset(strings.TrimPrefix(request.URL.Path, "/plugins/")) { + http.NotFound(writer, request) + return + } + next.ServeHTTP(writer, request) + }) +} diff --git a/internal/server/plugins_test.go b/internal/server/plugins_test.go new file mode 100644 index 0000000..1b3390a --- /dev/null +++ b/internal/server/plugins_test.go @@ -0,0 +1,35 @@ +package server + +import ( + "encoding/json" + "github.com/modelsphere/console/internal/config" + "log/slog" + "net/http" + "testing" + "testing/fstest" +) + +func pluginServer(t *testing.T) *Server { + t.Helper() + server := New(&config.Config{}, nil, slog.New(slog.DiscardHandler), "test") + server.SetWeb(fstest.MapFS{"index.html": {Data: []byte("SPA shell")}}) + return server +} + +func TestPluginsDefaultConfiguration(t *testing.T) { + handler := pluginServer(t).Handler() + response := do(handler, "GET", "/plugins/index.json", "", "") + var nodes []pluginManifest + if err := json.Unmarshal(response.Body.Bytes(), &nodes); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusOK || len(nodes) != 5 || nodes[0].ID != "inferences" || nodes[0].Source != "builtin" { + t.Fatalf("index: %d %s", response.Code, response.Body.String()) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("discovery must not be cached") + } + if missing := do(handler, "GET", "/plugins/example/index.js", "", ""); missing.Code != http.StatusNotFound { + t.Fatalf("unconfigured asset: %d", missing.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bc69241..5962e65 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -92,6 +92,11 @@ func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /readyz", s.handleReadyz) + mux.HandleFunc("GET /plugins/index.json", s.handlePluginIndex) + mux.HandleFunc("GET /plugins/{id}", http.NotFound) + mux.HandleFunc("GET /plugins/{id}/{asset...}", s.handlePluginAsset) + mux.HandleFunc("/plugins", http.NotFound) + mux.HandleFunc("/plugins/", http.NotFound) if s.authn != nil { mux.HandleFunc("POST /oauth/token", s.handleToken) @@ -130,7 +135,7 @@ func (s *Server) Handler() http.Handler { // is an ambiguous pair that ServeMux refuses. spa enforces GET/HEAD. mux.HandleFunc("/", s.spa) } - return s.recover(s.logRequests(s.authenticate(s.requirePasswordReset(mux)))) + return s.recover(s.logRequests(s.authenticate(s.requirePasswordReset(pluginPaths(mux))))) } func (s *Server) Run(ctx context.Context) error { @@ -217,6 +222,9 @@ type statusRecorder struct { status int } +// Unwrap lets ResponseController preserve WebSocket upgrades through logging. +func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } + func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) diff --git a/web/package-lock.json b/web/package-lock.json index c05f4d0..a9937e6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.104.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "highlight.js": "^11.12.0", "i18next": "^26.4.2", "i18next-icu": "^2.4.4", "intl-messageformat": "^11.2.15", @@ -1417,6 +1418,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1433,6 +1435,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1449,6 +1452,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1465,6 +1469,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1481,6 +1486,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1497,6 +1503,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1513,6 +1520,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1529,6 +1537,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1545,6 +1554,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1561,6 +1571,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1577,6 +1588,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1593,6 +1605,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1609,6 +1622,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1625,6 +1639,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1641,6 +1656,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1657,6 +1673,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1673,6 +1690,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1689,6 +1707,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1705,6 +1724,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1721,6 +1741,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2363,9 +2384,9 @@ } }, "node_modules/highlight.js": { - "version": "11.11.2", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", - "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" @@ -2820,6 +2841,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight/node_modules/highlight.js": { + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/lucide-react": { "version": "1.48.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", diff --git a/web/package.json b/web/package.json index 3d4c87b..df470a5 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "@tanstack/react-query": "^5.104.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "highlight.js": "^11.12.0", "i18next": "^26.4.2", "i18next-icu": "^2.4.4", "intl-messageformat": "^11.2.15", diff --git a/web/packages/plugin-sdk/.gitignore b/web/packages/plugin-sdk/.gitignore new file mode 100644 index 0000000..afbfc78 --- /dev/null +++ b/web/packages/plugin-sdk/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tsbuildinfo +*.tgz +.instance-type-contract-*/ diff --git a/web/packages/plugin-sdk/UPSTREAM.md b/web/packages/plugin-sdk/UPSTREAM.md new file mode 100644 index 0000000..dfc3f03 --- /dev/null +++ b/web/packages/plugin-sdk/UPSTREAM.md @@ -0,0 +1,3 @@ +# SDK source origin + +Source migrated from the generic plugin SDK in `theriseunion/rise-global-extension`, commit `e47501243`, package version `4.2.1`. ModelSphere maintains this package as `@modelsphere/plugin-sdk`. Registration and shared-host contracts remain compatible. Global plugin configuration-schema tests are outside this SDK package. diff --git a/web/packages/plugin-sdk/package.json b/web/packages/plugin-sdk/package.json new file mode 100644 index 0000000..611b29d --- /dev/null +++ b/web/packages/plugin-sdk/package.json @@ -0,0 +1,72 @@ +{ + "name": "@modelsphere/plugin-sdk", + "version": "0.1.0", + "private": true, + "description": "ModelSphere plugin SDK: shared host runtime, build presets and local development", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./build": { + "types": "./dist/build.d.ts", + "import": "./dist/build.mjs", + "require": "./dist/build.js" + }, + "./react-shim": { + "import": "./dist/react-shim.mjs", + "require": "./dist/react-shim.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.mjs", + "require": "./dist/testing.js" + }, + "./dev": { + "types": "./dist/dev.d.ts", + "import": "./dist/dev.mjs", + "require": "./dist/dev.js" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/modelsphere/console.git", + "directory": "web/packages/plugin-sdk" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "tsup": ">=8.0.0" + }, + "peerDependenciesMeta": { + "tsup": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "19.2.18", + "@types/react-test-renderer": "19.1.0", + "react": "19.2.8", + "react-test-renderer": "19.2.8", + "tsup": "8.5.1", + "vitest": "5.1.0" + }, + "files": [ + "dist" + ], + "dependencies": { + "@tailwindcss/oxide": "4.3.3", + "@tailwindcss/vite": "4.3.4", + "@vitejs/plugin-react": "6.2.1", + "vite": "8.3.6", + "typescript": "5.9.3" + } +} diff --git a/web/packages/plugin-sdk/pnpm-lock.yaml b/web/packages/plugin-sdk/pnpm-lock.yaml new file mode 100644 index 0000000..d36c3fa --- /dev/null +++ b/web/packages/plugin-sdk/pnpm-lock.yaml @@ -0,0 +1,1891 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tailwindcss/oxide': + specifier: 4.3.3 + version: 4.3.3 + '@tailwindcss/vite': + specifier: 4.3.4 + version: 4.3.4(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0)) + '@vitejs/plugin-react': + specifier: 6.2.1 + version: 6.2.1(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0)) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 8.3.6 + version: 8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0) + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.5 + '@types/react': + specifier: 19.2.18 + version: 19.2.18 + '@types/react-test-renderer': + specifier: 19.1.0 + version: 19.1.0 + react: + specifier: 19.2.8 + version: 19.2.8 + react-test-renderer: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: 8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.32)(typescript@5.9.3) + vitest: + specifier: 5.1.0 + version: 5.1.0(@types/node@22.20.5)(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0)) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.67.5': + resolution: {integrity: sha512-FdWPeulkH+FH+rBzyKBD59tuVN4tRKvmVilSJm0OcnoYsQTpd9UhD78OaPcsYqo2fX6hCwAG0YIjrDLoQ3pR3g==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.67.5': + resolution: {integrity: sha512-bmkDuBGnF+Ji8atWlXcNjxIC52ShCbdXPbW7Nz0lRoCFi1lrvsO0XobJY36kTj7duc1vOmGJeQ9vtVuGjnzihA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-x64@4.67.5': + resolution: {integrity: sha512-6KvVfnqPTqmbQ5YobMl9HYiSymAsCt8HlNeriDshwJQcveXwT+vIMW6wGL1RcJbXH9+r3BAJvjab5+rIgtSEqA==} + cpu: [x64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.67.5': + resolution: {integrity: sha512-SNamgdrY69oPdBrQV9fKGo6PSmKcVhyn0D0jnDmcqgRJvrxQGGBhtekOHTUJ7TCgkW0nE8XMH9v2CwXmjNIzVA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.67.5': + resolution: {integrity: sha512-HNdl34thiJtHDSglogjmAlQ296L8cc8zA7ODH65nnmY7E+jPP55WEbh8J5583rWFLMjxyYywxZuwPOwRvEi5tA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.67.5': + resolution: {integrity: sha512-WvZYtANNswfHzSNHaJAoG6Ipfc4SyZIDYuIjFdHv07xXxhHl+Xx8o+bNDWsxpOADFT2mKXcXVrfOG4OlWTLo8Q==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.67.5': + resolution: {integrity: sha512-EUU+Wgf9NyHekceC+DxAdm42f6bq2ezH/7DEb/JnCb8SAUhG2xph5BiVukxplWal2m5pP8CzJZE+FLwRopywjQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + resolution: {integrity: sha512-dgU1CvUuE2IkaJBAeuAbxpDdMHAdZBT5oth5u22DtuoMg4gXXYr2+Sm30RigPJG1F2VEoiGJUziGLuIh9+jPxA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + resolution: {integrity: sha512-rdgJcJig2VngYlcTCRQw2BA8kLWM6QwDFe9uLFo81lya0jPC1r8wZu92sAvTUIv48GWlkRVi6/fpsa5CoFSKoQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + resolution: {integrity: sha512-+jtCiQnrEFHWAUAcUpP7wppb1p6HKOgBP3mXx75cEYpDF2Y5MEqBky2HNz+osFuVx5uX44gnMTmbe5RJauzn6A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.67.5': + resolution: {integrity: sha512-heflX7ENhL8eHmLcUxDEHCSK8CKLyPprysb2JGoSTh5Ip7nHrzIRc+2USyxul3nxi98H8pGxJIEDEpURlS1X5A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + resolution: {integrity: sha512-rRtNU0zBfRHkJ8pscHN9qRtUYLq8Rao4EX7aVWvKcFmJUANkCCXFRU7ovNBEMhpEONyUEB/3DRVDXwDDguSfHA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.67.5': + resolution: {integrity: sha512-/6auyi9yj8MD/Q+8P+f2ay57jQ7a0j7Yp1JK86pecH6ZnRViQZ5GOjoRSjGArFeTSHFznRbM2QPQcBgDPyNMfQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + resolution: {integrity: sha512-Hsc0vlt+vPvqzUkHKffWKf3YE/yexrY+yeFf7IsUvDC6PV7YGCsA/gJvPa+WJdgxGJnwuoO1HmLkbahaLKabYQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + resolution: {integrity: sha512-Jx5TMMSPJwHY7F/JwPdS6nuBwny2ECRK+EsAJV5HZrfiddeSS6ZrohkLkFZm3rjqBUuQ4FPX5gGMtakUuQO6fQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + resolution: {integrity: sha512-fGWcq2lo/XCqAkBLOokg66D/z+MUouryB8i+QdU9RR6O5knG7rucc0WFPqWYL1Wk/wuiJouDfmyBxmZKRM9bag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + resolution: {integrity: sha512-JwO/Ys3jybc0UW3V5XO2CMnp0RhvoczDDQTgAEE1IctT+HRY3W5parIIWLUpmAK4gHSeeYJofm2iQPFA62K6ag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + resolution: {integrity: sha512-vVD4iqel6J6A1xAJlb4mxEO9i1wOJ4/SkcKoDKAuK/wqEBPOA7/P5dDyZvNx46ocHwOUwzo3WjhTdKqET9kEAg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.67.5': + resolution: {integrity: sha512-HmEfNh5Cdd/NHxgqwMzKnjlvicQQA+D+w+vQXDJSV0x/aHiQSjVwh1Th5RoA7QsMieIaxibt/5iJq68BraTSUg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.67.5': + resolution: {integrity: sha512-NfmALpbe009nYzZ2QqlSW6nzbGddXpqk3nwualrIr+4QflQCTGMzt3QKK20yARiAnSV9fJdM5WNwIYUf31x3uA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.67.5': + resolution: {integrity: sha512-ngcE7Udpy/iJ3lexVhYcbpUV2OJIakRGus3snc6K52BnAAJetDBG1Ku5Eh3CECtnW3mpBYWg5VCD4tTaOXKOVQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.67.5': + resolution: {integrity: sha512-DZ01A2EdTYX8xVeGoGiWkJBRWDh+/BOflwGPVsBay43PDxcq8ez0xuovZnPnk1xjE3CdI0pdZXxsJp/Xjmf7uQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + resolution: {integrity: sha512-nJKiMKDO140DUCEcQZl+f4HiGPMBxrW2webjuWJ2E3qPeoruHf2zByml4TP52lA1zDi6L4I92D/5stxeA/6pig==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + resolution: {integrity: sha512-ykLkBCf+Kls24QmqM3IRf1T4sGMh0rXPyymtj+hXlWKPJnXbXraHqUUsKtOIxqLasjT1i7DhfQGAaZYeiHbSOw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.67.5': + resolution: {integrity: sha512-NiFPUFw8cHhkmBlL1k1eaz+5AXXm89rztj+DQdDuQ7VGPM0+ADFhezQ2KcBMF50UEFzGUr0+DVjVND743XwKWQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.67.5': + resolution: {integrity: sha512-Khj/vrKCpdbiAWYbJAeSy4D5iep+qwW/86lNoKvdleJYGxgbRE7h87+5d5T9D7gt8NcqhNFpreRgIg8T4bLzKQ==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.3.4': + resolution: {integrity: sha512-b7QK/QG04v3j04NbpG82XLCedRyulnkuwUDwRa9oK/ypnJIzMapz2Wg+0TltVczjUzDaJJHIa1+jj9aMUOFMTQ==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-android-arm64@4.3.4': + resolution: {integrity: sha512-qkjxt+EFocOi3mtsUS3ndlj6yj4SHubwBdvBynLnS8T4mvSzX0Enn0CpypJKmBEtczUAXOHVZKA2zTGzSNU2Sg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-arm64@4.3.4': + resolution: {integrity: sha512-i/awUA/7mJQAbA3AzCW8aQtUh5goMieUdF1zOoclLaJUCxDvsXQ8VJqpQZY3dfcHS6PJjd3tw25nNneD/mEviQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.4': + resolution: {integrity: sha512-q56V9U6AnoYz/yj/alkDLhSQ9AZoXQSEl93f7WETa0+32CudbqM57BXdYXfMYPKudueXKaA+MG6L/0KRseF7Jg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-freebsd-x64@4.3.4': + resolution: {integrity: sha512-r0W7A+Z9HTHh7bwihJl45E/zZKLjxD50/jToVF/E/RZHSl8+EWO+4yKKJpPMcUooT9hdoip3Zx1uWnbMg4N79g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.4': + resolution: {integrity: sha512-3aih/sRxzeYfi2Bkh+bGYNf2YU9Llamb2mpA7BNF1obYzdgEeWDAMQzK6BSwzLhs/eTdOaSur92qrGfchagUtQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.4': + resolution: {integrity: sha512-ktJSrPGQou4OqL88rDEx5CkeM9BjzrwZvYo8u7B7VL1zkBa2XBm351o4MQrek95qikb2M++26Y7w7vbH6Mgl8g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.4': + resolution: {integrity: sha512-rt3hjUxT7utAtcB+df6wHyaXDyxhE7Zd1x7L/nvB5Jb2qBb8dZwFUoWqeRja1BaF5OlSqkKSjBmVXj9OsevmCQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.4': + resolution: {integrity: sha512-XE3jO2yOOWjttpeGvpwf3/b274NMi3QapQSAI/EzsgrmYNan1Tw8dXT9JQkN7K0OgvuqiC8mcCyNvGzEkpVJPQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.4': + resolution: {integrity: sha512-PaAZ2FjJaZk9SQLYtKIfQWiFm/xtl7pX2MKPgCzn0mS/VU54yu+vP457AJdB2XFLxU3gO2abLCt8N7Lg4EbKNg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-wasm32-wasi@4.3.4': + resolution: {integrity: sha512-bjO9WmoM687p8AoCIyw1DUqzpAs4JICLvNmIPxIWDmhw8IOsdubtWQLShyJmISnDKtRy1BBApHVOUl6sNFQQJA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.4': + resolution: {integrity: sha512-jCrwCvWVtt7ruMXp6dFPd5KBNU8o1tWtEdZbTL/w/hvD3lUYZA0E2mmfvTzW4QLHIx5wGQU9HeY7997R1QTrEg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.4': + resolution: {integrity: sha512-cge1wcedn1FrRSXL22lF5sNDYr8c9+VImk7zFYLPs12jkDmchSrZNfzWqOKub+5Hb/aYRdDEqERGGDKONBlOzQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide@4.3.4': + resolution: {integrity: sha512-riqsRw2sLF7B1sNCCkTl3KYXEqvhTiABzZ/4oxxHfZ3c3Td60bCBfOrUuUGOiHagd3E7V+zG46c+XBDWZGFBcg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.4': + resolution: {integrity: sha512-wGukGWb8SVuN7+FJvSS7M5eSJWDIWwVSvUvj1q41eTIhGPsS2DAgFWjA7YURJNwjHSgty59Hz8iMUiyO+YMHBQ==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@types/react-test-renderer@19.1.0': + resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@6.2.1': + resolution: {integrity: sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 || ^0.3.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: '>=0.152.0 <0.157.0' + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.1.0': + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.1.0': + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.26.1: + resolution: {integrity: sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==} + engines: {node: '>=10.13.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.4: + resolution: {integrity: sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + react-is@19.3.1: + resolution: {integrity: sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==} + + react-test-renderer@19.2.8: + resolution: {integrity: sha512-GHKPaDRaNYU24PHTLG8Bx8VMY9t+qNfxQbt/Yjp7aMWBkKU6766SR0n6TnYu7P5I1MfEuAMUadqiyDHyI4Yy9Q==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.67.5: + resolution: {integrity: sha512-3Ve5VH1hacCVutdpbmu/htg4kz+JCOIWgy4Q3hjHPYGHeHeIElNHJ0LcJ3rCfIsd5G7qOl5k5mwpzGMj9kdHPA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + tailwindcss@4.3.4: + resolution: {integrity: sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@6.2.3: + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} + engines: {node: '>=20.0.0'} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@8.3.6: + resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.1.0: + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.1.0 + '@vitest/browser-preview': 5.1.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.1.0 + '@vitest/coverage-v8': 5.1.0 + '@vitest/ui': 5.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.156.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.16': + optional: true + + '@rolldown/binding-android-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-x64@1.2.16': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.16': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.16': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.16': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.16': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.16': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.67.5': + optional: true + + '@rollup/rollup-android-arm64@4.67.5': + optional: true + + '@rollup/rollup-android-x64@4.67.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.67.5': + optional: true + + '@rollup/rollup-darwin-x64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.67.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.67.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.67.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.67.5': + optional: true + + '@tailwindcss/node@4.3.4': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.26.1 + jiti: 2.7.0 + lightningcss: 1.33.0 + magic-string: 1.4.5 + source-map-js: 1.2.2 + tailwindcss: 4.3.4 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-android-arm64@4.3.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.4': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/oxide@4.3.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.4 + '@tailwindcss/oxide-darwin-arm64': 4.3.4 + '@tailwindcss/oxide-darwin-x64': 4.3.4 + '@tailwindcss/oxide-freebsd-x64': 4.3.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.4 + '@tailwindcss/oxide-linux-x64-musl': 4.3.4 + '@tailwindcss/oxide-wasm32-wasi': 4.3.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.4 + + '@tailwindcss/vite@4.3.4(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.4 + '@tailwindcss/oxide': 4.3.4 + tailwindcss: 4.3.4 + vite: 8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0) + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@types/react-test-renderer@19.1.0': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.2.1(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0) + + '@vitest/mocker@5.1.0(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.1.0 + estree-walker: 3.0.3 + magic-string: 1.4.5 + optionalDependencies: + vite: 8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0) + + '@vitest/spy@5.1.0': {} + + acorn@8.19.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@6.3.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + enhanced-resolve@5.26.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-module-lexer@2.3.2: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.67.5 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + jiti@2.7.0: {} + + joycon@3.1.1: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lilconfig@3.1.4: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mlly@1.8.2: + dependencies: + acorn: 8.19.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.20: {} + + object-assign@4.1.1: {} + + obug@3.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.32): + dependencies: + lilconfig: 3.1.4 + optionalDependencies: + jiti: 2.7.0 + postcss: 8.5.32 + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + react-is@19.3.1: {} + + react-test-renderer@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + react-is: 19.3.1 + scheduler: 0.27.0 + + react@19.2.8: {} + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rolldown@1.2.16: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 + + rollup@4.67.5: + dependencies: + '@types/estree': 1.0.10 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.67.5 + '@rollup/rollup-android-arm64': 4.67.5 + '@rollup/rollup-android-x64': 4.67.5 + '@rollup/rollup-darwin-arm64': 4.67.5 + '@rollup/rollup-darwin-x64': 4.67.5 + '@rollup/rollup-freebsd-arm64': 4.67.5 + '@rollup/rollup-freebsd-x64': 4.67.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.67.5 + '@rollup/rollup-linux-arm-musleabihf': 4.67.5 + '@rollup/rollup-linux-arm64-gnu': 4.67.5 + '@rollup/rollup-linux-arm64-musl': 4.67.5 + '@rollup/rollup-linux-loong64-gnu': 4.67.5 + '@rollup/rollup-linux-loong64-musl': 4.67.5 + '@rollup/rollup-linux-ppc64-gnu': 4.67.5 + '@rollup/rollup-linux-ppc64-musl': 4.67.5 + '@rollup/rollup-linux-riscv64-gnu': 4.67.5 + '@rollup/rollup-linux-riscv64-musl': 4.67.5 + '@rollup/rollup-linux-s390x-gnu': 4.67.5 + '@rollup/rollup-linux-x64-gnu': 4.67.5 + '@rollup/rollup-linux-x64-musl': 4.67.5 + '@rollup/rollup-openbsd-x64': 4.67.5 + '@rollup/rollup-openharmony-arm64': 4.67.5 + '@rollup/rollup-win32-arm64-msvc': 4.67.5 + '@rollup/rollup-win32-ia32-msvc': 4.67.5 + '@rollup/rollup-win32-x64-gnu': 4.67.5 + '@rollup/rollup-win32-x64-msvc': 4.67.5 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + source-map-js@1.2.2: {} + + source-map@0.7.6: {} + + std-env@4.3.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + tailwindcss@4.3.4: {} + + tapable@2.3.3: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@6.2.3: {} + + tinyexec@0.3.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.32)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.32) + resolve-from: 5.0.0 + rollup: 4.67.5 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.32 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.16 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.5 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@5.1.0(@types/node@22.20.5)(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.1.0(vite@8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.5 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.6(@types/node@22.20.5)(esbuild@0.27.7)(jiti@2.7.0) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 22.20.5 + transitivePeerDependencies: + - msw + + why-is-node-running@3.2.1: {} diff --git a/web/packages/plugin-sdk/src/build.test.ts b/web/packages/plugin-sdk/src/build.test.ts new file mode 100644 index 0000000..48456a4 --- /dev/null +++ b/web/packages/plugin-sdk/src/build.test.ts @@ -0,0 +1,24 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { writePluginCandidates } from './build' + +describe('plugin CSS candidates', () => { + it('honors source patterns while leaving the default scan unchanged', () => { + const root = mkdtempSync(path.join(tmpdir(), 'plugin-candidates-')) + try { + mkdirSync(path.join(root, 'src')) + writeFileSync(path.join(root, 'src', 'page.tsx'), '
') + writeFileSync(path.join(root, 'src', 'page.test.mjs'), 'const fixture = "bg-blue-500"') + const read = () => JSON.parse(readFileSync(path.join(root, 'frontend', 'candidates.json'), 'utf8')) as string[] + writePluginCandidates({ name: 'test', root }) + expect(read()).toEqual(expect.arrayContaining(['bg-red-500', 'bg-blue-500'])) + writePluginCandidates({ name: 'test', root, cssSourcePatterns: ['**/*.ts', '**/*.tsx'] }) + expect(read()).toContain('bg-red-500') + expect(read()).not.toContain('bg-blue-500') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/web/packages/plugin-sdk/src/build.ts b/web/packages/plugin-sdk/src/build.ts new file mode 100644 index 0000000..3733a3e --- /dev/null +++ b/web/packages/plugin-sdk/src/build.ts @@ -0,0 +1,162 @@ +import ts from "typescript"; +import { sharedImports } from "./shared-imports"; +// @modelsphere/plugin-sdk — Build preset for Rise Global UMD plugins +// +// 平台构建约定的唯一实现(freeland#110 封装原则:样板下沉 SDK,插件仓零复制): +// IIFE 单文件、React 取自宿主 window.__RISE_SDK__(banner + alias 到包内 shim)、 +// 产物 frontend/index.umd.js + frontend/candidates.json。插件仓的 tsup.config.ts 只剩: +// +// import { definePluginConfig } from '@modelsphere/plugin-sdk/build' +// export default definePluginConfig({ name: 'my-plugin' }) +// +// 运行环境是 Node(tsup 配置上下文),不是浏览器 —— 不要从这里 import 浏览器代码。 +// +// ── frontend/candidates.json:插件只交「用了哪些类」,宿主一次编译(freeland#176)── +// 插件 UMD 是纯 JS,样式此前完全吃宿主编译好的那份 Tailwind —— 宿主没用过的工具类在 +// 插件里写了也不生效、且**静默**。 +// +// 第一版修法是每个插件自己产 index.css,当天就翻车:47 份独立构建的样式表拼进一个文档, +// 别的插件的 `.hidden` 排在你的 `.lg:block` 后面就把它压掉了 —— **Tailwind 只保证一份 +// 样式表内的规则顺序**,多份表之间没有任何保证。唯一能保证顺序的地方是 Tailwind 自己, +// 所以插件只交候选类清单(用 Tailwind 自己的 oxide 扫描器扫本插件 src/,与 Tailwind +// 构建时看到的一模一样,不是正则近似),宿主 server.js 把所有已安装插件的清单并集**一次** +// 编译成一张 /plugins/_merged.css。插件仓不装 Tailwind、不碰主题:主题 / 变体 / 暗色 / +// 密度全在宿主那一份契约里。 + +import path from 'node:path' +import fs from 'node:fs' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { Scanner } from '@tailwindcss/oxide' +import { defineConfig, type Options } from 'tsup' + +export interface PluginBuildOptions { + /** 插件名(plugin.yaml 的 name),仅用于日志与排错信息 */ + name: string + /** 入口文件,默认 src/index.tsx */ + entry?: string + /** 产物目录,默认 frontend(server.js 热加载与 helm package 都读这里) */ + outDir?: string + /** + * 类清单的扫描目录,默认 src。**只扫自己**:扫到别的插件 / node_modules 就等于替别人报类。 + * 传 `false` 关掉 —— 只有确实没有一行 JSX 的纯逻辑插件才该这么做。 + */ + cssSource?: string | false + /** Glob patterns relative to cssSource. Defaults to all files. */ + cssSourcePatterns?: string[] + /** 追加/覆盖 tsup 选项(逃生口,慎用 —— 偏离约定的配置应先回到 SDK 讨论) */ + tsup?: Partial +} + +const here = path.dirname(fileURLToPath(import.meta.url)) + +/** 产物文件名。宿主 `lib/plugin-css.js` 按 entryURL 同目录 + 这个名字取,改名两边一起改。 */ +export const CANDIDATES_FILE = 'candidates.json' + +/** + * 从插件根目录的 `Chart.yaml` 读出 `version`,作为 `__PLUGIN_VERSION__` 注入构建产物 + * (project#341):`createPlugin({ version })` 此前是插件里手写的字面量,没有任何东西 + * 校验它与 Chart.yaml 一致 —— `plugin-chart-guard.yml` 只强制 bump Chart.yaml,不看这个 + * 字面量。实测 2026-09-10:main 上声明了 version 的 49 个插件,48 个与 Chart.yaml 不一致 + * (如 model-x 的 Chart.yaml 已到 0.150.0,前端注册的还是脚手架时的 0.1.0)。 + * + * 只读顶层 `version:`(Helm chart 版本本来就不加引号,也不允许缩进出现在别的键下), + * 正则同时兼容裸写和带引号两种写法,防将来有人手滑加引号。 + * + * ⚠️ **`tsup --watch` 只在启动时读一次**:tsup.config.ts 全程只求值一次,`--watch` + * 重建的是入口的 JS 依赖图,不会重新执行配置文件,Chart.yaml 也不在那张依赖图里。 + * 本地开发时若在 watcher 跑着的时候手动改 Chart.yaml 的 version,产物里的 + * __PLUGIN_VERSION__ 要重启 watcher 才会更新——跟改 tsup.config.ts 本身要重启一样, + * 不是本函数独有的限制。CI 每次构建都是全新进程,不受影响。 + */ +// 构建期(Node)抛错,只给插件作者看、进不了浏览器,**不国际化**: +// 国际化规范 §1「开发者日志、调试文本和内部诊断不是默认的用户文案」= design spec §4.4 豁免 1。 +export function readChartVersion(root: string, pluginName: string): string { + const file = path.join(root, 'Chart.yaml') + if (!fs.existsSync(file)) { + throw new Error(`[plugin-sdk] ${pluginName}: 找不到 ${file} —— 插件版本以 Chart.yaml 的 version 为准`) + } + const match = fs.readFileSync(file, 'utf8').match(/^version:\s*['"]?([^'"\s#]+)/m) + if (!match) { + throw new Error(`[plugin-sdk] ${pluginName}: ${file} 里没有顶层 version 字段`) + } + return match[1] +} + +/** + * 扫描 `/` 里的 Tailwind 候选类 → `//candidates.json`(排序、去重)。 + * + * 用 `@tailwindcss/oxide` 的 Scanner —— 这就是 Tailwind 构建时用的扫描器,插件源码里能被 + * Tailwind 认出的候选一个不漏、一个不多(此前宿主 safelist 用的正则扫描漏过 cn() 里的 + * 字符串、任意值变体等好几类,见 freeland#176)。清单里会混入不是类的普通单词,无害: + * 宿主编译时 Tailwind 会忽略认不出的候选。 + * + * 独立导出:不用 definePluginConfig 的自定义构建也能单独调用。 + */ +export function writePluginCandidates(opts: { name: string; root?: string; outDir?: string; cssSource?: string; cssSourcePatterns?: string[] }): string { + const root = opts.root ?? process.cwd() + const source = path.resolve(root, opts.cssSource ?? 'src') + const outDir = path.resolve(root, opts.outDir ?? 'frontend') + const outFile = path.join(outDir, CANDIDATES_FILE) + if (!fs.existsSync(source)) { + throw new Error(`[plugin-sdk] ${opts.name}: 类清单扫描目录不存在 ${source}(传 cssSource 指定,或 cssSource:false 关闭)`) + } + const scanner = new Scanner({ sources: (opts.cssSourcePatterns ?? ['**/*']).map(pattern => ({ base: source, pattern, negated: false })) }) + const candidates = [...new Set(scanner.scan())].sort() + fs.mkdirSync(outDir, { recursive: true }) + fs.writeFileSync(outFile, JSON.stringify(candidates) + '\n') + console.log(`[plugin-sdk] ${opts.name}: ${path.relative(root, outFile)} ${candidates.length} candidates`) + return outFile +} + +const sharedReactOptions: NonNullable = (options) => { + const shim = path.join(here, 'react-shim.mjs') + const jsxShim = path.join(here, 'react-jsx-runtime-shim.mjs') + options.jsx = 'transform' + options.jsxFactory = 'React.createElement' + options.jsxFragment = 'React.Fragment' + options.alias = { + ...(options.alias ?? {}), + 'react/jsx-runtime': jsxShim, + 'react/jsx-dev-runtime': jsxShim, + react: shim, + 'react-dom': shim, + } +} + +export function definePluginConfig(opts: PluginBuildOptions) { + const outDir = opts.outDir ?? 'frontend' + const root = process.cwd() + const chartVersion = readChartVersion(root, opts.name) + const userOnSuccess = opts.tsup?.onSuccess + const { define: userDefine, ...restTsupOverrides } = opts.tsup ?? {} + return defineConfig({ + entry: { 'index.umd': opts.entry ?? 'src/index.tsx' }, + format: ['iife'], + platform: 'browser', + outDir, + outExtension: () => ({ js: '.js' }), + minify: true, + external: [], + banner: { + js: 'var React = (window.__MODELSPHERE_SDK__ || window.__GLOBAL_SDK__ || window.__RISE_SDK__).React;', + }, + define: { + 'process.env.NODE_ENV': '"production"', + __PLUGIN_VERSION__: JSON.stringify(chartVersion), + ...userDefine, + }, + esbuildOptions: sharedReactOptions, + ...restTsupOverrides, + esbuildPlugins: [sharedImports(path.resolve(root, opts.cssSource || 'src'), ts), ...(opts.tsup?.esbuildPlugins ?? [])], + async onSuccess() { + if (opts.cssSource !== false) { + writePluginCandidates({ name: opts.name, outDir, cssSource: opts.cssSource, cssSourcePatterns: opts.cssSourcePatterns }) + } + if (typeof userOnSuccess === 'function') return userOnSuccess() + if (typeof userOnSuccess === 'string') { + execFileSync(userOnSuccess, { shell: true, stdio: 'inherit' }) + } + }, + }) +} diff --git a/web/packages/plugin-sdk/src/cluster-display-name.ts b/web/packages/plugin-sdk/src/cluster-display-name.ts new file mode 100644 index 0000000..75b0b2d --- /dev/null +++ b/web/packages/plugin-sdk/src/cluster-display-name.ts @@ -0,0 +1,8 @@ +import { getBusiness } from './host' +import type { ReactNode } from 'react' + +/** 仅供列表单元格显示;调用方继续用原集群名处理筛选、排序、请求和导出。 */ +export function useClusterDisplayName(options?: Array<{ name: string; displayName?: string }>, loading = false): (name: string) => ReactNode { + return getBusiness<{ useClusterDisplayName: (options?: Array<{ value: string; label: string }>, loading?: boolean) => (name: string) => ReactNode }>() + .useClusterDisplayName(options?.map(option => ({ value: option.name, label: option.displayName || option.name })), loading) +} diff --git a/web/packages/plugin-sdk/src/cluster-resource-api.ts b/web/packages/plugin-sdk/src/cluster-resource-api.ts new file mode 100644 index 0000000..baede28 --- /dev/null +++ b/web/packages/plugin-sdk/src/cluster-resource-api.ts @@ -0,0 +1,365 @@ +// Cluster-scoped request helper for platform API paths (ADR 0029, URL scope 与授权). +// scopedApi is path-agnostic: any /oapis/, /api/ or /apis/ relative path is +// accepted. What the /clusters/{cluster} prefix means differs per API family +// (see the README table 三类 API 的行为差异), so callers must know the +// semantics of the API they address. The SDK only guarantees wire mechanics: +// it refuses absolute URLs, dot segments, encoded separators, control +// characters and a pre-prefixed /clusters/ path, and it encodes the cluster +// name exactly once. + +import { sdkT } from './i18n' + +export const RESOURCES_FACADE_PREFIX = '/oapis/resources.theriseunion.io/v1alpha1/' + +/** Authorization axis (ADR 0029): + * consumption = 用户侧,裸路径按 Namespace/Workspace 权限; + * cluster = 管理侧,显式 /clusters/{cluster} 按 Cluster 权限。 + * This is the request's scope, not the plugin viewport or user's role. */ +export type ResourceRequestScope = + | { axis: 'consumption' } + | { axis: 'cluster'; cluster: string } + +export class RiseResourcePathError extends Error { + constructor(message: string) { + super(message) + this.name = 'RiseResourcePathError' + } +} + +export class RiseResourceError extends Error { + readonly status: number + readonly method: string + readonly url: string + readonly body: unknown + + constructor(opts: { + status: number + method: string + url: string + message: string + body?: unknown + }) { + super(opts.message) + this.name = 'RiseResourceError' + this.status = opts.status + this.method = opts.method + this.url = opts.url + this.body = opts.body + } +} + +function pathWithoutQuery(url: string): string { + return url.split(/[?#]/, 1)[0] +} + +// 下面这些 `[modelsphere-plugin-sdk]` 抛错是插件作者的用法诊断,**不国际化**(写死中文是规定动作): +// 国际化规范 §1「开发者日志、调试文本和内部诊断不是默认的用户文案」= design spec §4.4 豁免 1。 +// 后端返回的报错、网络与鉴权失败是另一回事 —— 那些会显给用户,走 `resourceApi.error.*`。 +const DNS1123_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ + +const RESERVED_NAMESPACE_VALUES = new Set(['_all', '_all_']) + +function extractNamespaceSegment(apiPath: string): { name: string } | null { + const match = apiPath.match(/\/namespaces\/([^/?#]+)/) + if (!match) return null + const name = match[1] + if (RESERVED_NAMESPACE_VALUES.has(name)) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] namespace 段不能是保留值(' + name + ',全部聚合视图没有 ns label 路由保证)。请逐 namespace 请求。', + ) + } + if (!isValidDNS1123Label(name)) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] namespace 段不是合法的 DNS1123 label:' + name, + ) + } + return { name } +} + +function isValidDNS1123Label(value: string): boolean { + return value.length <= 63 && DNS1123_LABEL.test(value) +} + +function isValidDNS1123Subdomain(value: string): boolean { + if (value.length > 253) return false + const labels = value.split('.') + return labels.every((label) => isValidDNS1123Label(label)) +} + +function assertNoPathTraversal(apiPath: string): void { + if (apiPath.includes('\\')) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] path 含反斜杠(浏览器会按分隔符规范化),已拒绝。', + ) + } + const segments = apiPath.split('/') + for (const segment of segments) { + let decoded: string + try { + decoded = decodeURIComponent(segment) + } catch { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] path 段含非法百分号编码(' + segment + '),已拒绝。', + ) + } + if (decoded === '.' || decoded === '..') { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] path 含 dot segment(' + segment + '),已拒绝。请传规范化后的路径。', + ) + } + } + if (/%2e/i.test(apiPath) || /%2f/i.test(apiPath) || /%5c/i.test(apiPath)) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] path 含编码的分隔符(%2e/%2f/%5c),已拒绝。', + ) + } + if (/[\x00-\x1f]/.test(apiPath)) { + throw new RiseResourcePathError('[modelsphere-plugin-sdk] path 含控制字符,已拒绝。') + } +} + +// Only platform-relative API paths are accepted: the three roots the gateway +// routes. Anything else (static assets, /login, made-up prefixes) is rejected +// so a typo cannot silently become a same-origin page fetch. +const API_ROOTS = ['/oapis/', '/api/', '/apis/'] + +function assertApiPath(apiPath: string): void { + if (!API_ROOTS.some((root) => apiPath.startsWith(root))) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] scopedApi 只接受平台 API 相对路径(/oapis/…、/api/…、/apis/…),收到:' + + apiPath + + '。平台外的地址请用插件自己的 fetch,不要带平台凭据出域。', + ) + } +} + +function validateClusterName(cluster: string): void { + if (cluster === '') { + throw new RiseResourcePathError( + "[modelsphere-plugin-sdk] axis:'cluster' 需要非空 cluster 名(真实集群名或 'host')。", + ) + } + if (cluster === '_all') { + throw new RiseResourcePathError( + "[modelsphere-plugin-sdk] '_all' 是「全部集群」保留值,不是集群名;" + + "axis:'cluster' 下无法表达「读全部集群」。请逐集群显式请求,或在消费轴按 Namespace 归属读。", + ) + } + if (!isValidDNS1123Subdomain(cluster)) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] cluster 不是合法的 DNS1123 subdomain:' + cluster, + ) + } + +} + +export function buildResourcePath(path: string, scope: ResourceRequestScope): string { + if (/^https?:\/\//i.test(path) || path.includes('://')) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] scopedApi 不接受绝对 URL(' + path + ')。' + + '它只请求当前平台;平台外的地址请用插件自己的 fetch,不要带平台凭据出域。', + ) + } + if (!path.startsWith('/')) { + throw new RiseResourcePathError( + "[modelsphere-plugin-sdk] scopedApi path 必须是以 '/' 开头的相对路径,收到:" + path, + ) + } + if (path.includes('#')) { + throw new RiseResourcePathError('[modelsphere-plugin-sdk] scopedApi path 不接受 URL fragment(#)。') + } + const apiPath = pathWithoutQuery(path) + if (apiPath.startsWith('/clusters/')) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] path 已带 /clusters/ 前缀(' + path + '),请传裸路径并用 scope 声明授权轴。', + ) + } + assertApiPath(apiPath) + assertNoPathTraversal(apiPath) + // A namespace segment is validated whenever present, regardless of axis or + // API family — every accepted family namespaces resources the same way. + extractNamespaceSegment(apiPath) + + if (scope.axis === 'consumption') { + return path + } + + const { cluster } = scope + validateClusterName(cluster) + + return '/clusters/' + encodeURIComponent(cluster) + path +} + +export interface ClusterViewScope { + cluster: string +} + +export function resolveClusterViewScope(pathname?: string): ClusterViewScope | null { + let target: string + if (typeof pathname === 'string') { + target = pathname + } else { + if (typeof window === 'undefined') return null + target = window.location.pathname + } + + const currentScopeMatch = target.match(/^\/cluster\/([^/]+)(?:\/|$)/) + if (currentScopeMatch) target = currentScopeMatch[1] + else { + const legacyMatch = target.match(/^\/(?:boss|metrics|express)\/clusters\/([^/]+)(?:\/|$)/) + if (!legacyMatch) return null + target = legacyMatch[1] + } + + if (target === '_all') return null + let cluster: string + try { + cluster = decodeURIComponent(target) + } catch { + return null + } + return isValidDNS1123Subdomain(cluster) ? { cluster } : null +} + +export function clusterViewScopeOrThrow(): ResourceRequestScope { + const view = resolveClusterViewScope() + if (!view) { + throw new RiseResourcePathError( + '[modelsphere-plugin-sdk] 当前页面不在 cluster 视图(URL 无 /cluster/{c} 段),' + + "无法推导供给轴。project/workspace 页面请用 { axis: 'consumption' }。", + ) + } + return { axis: 'cluster', cluster: view.cluster } +} + +export interface ResourceRequestOpts { + params?: Record + signal?: AbortSignal +} + +export interface ResourceResponse { + data: T + status: number +} + +function extractErrorMessage(body: unknown, status: number, statusText: string): string { + if (body && typeof body === 'object') { + const record = body as Record + const message = record.message ?? record.error + if (typeof message === 'string' && message.trim() !== '') return message + } + if (typeof body === 'string' && body.trim() !== '') return body.trim() + return 'HTTP ' + status + (statusText ? ' ' + statusText : '') +} + +async function parseBody(response: Response): Promise { + if (response.status === 204 || response.status === 205) return null + const text = await response.text() + if (text === '') return null + try { + return JSON.parse(text) + } catch { + return text + } +} + +function mergeQuery(path: string, params?: Record): { pathname: string; query: string } { + const separator = path.indexOf('?') + const pathname = separator < 0 ? path : path.slice(0, separator) + const existingQuery = separator < 0 ? '' : path.slice(separator + 1) + const search = new URLSearchParams(existingQuery) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value == null || value === '') continue + search.set(key, String(value)) + } + } + const query = search.size > 0 ? '?' + search.toString() : '' + return { pathname, query } +} + +async function request( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + scope: ResourceRequestScope, + opts: ResourceRequestOpts & { body?: unknown; contentType?: string } = {}, +): Promise> { + const built = buildResourcePath(path, scope) + const { pathname, query } = mergeQuery(built, opts.params) + const url = pathname + query + + let response: Response + try { + response = await fetch(url, { + method, + credentials: 'include', + headers: + opts.body !== undefined ? { 'Content-Type': opts.contentType ?? 'application/json' } : undefined, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: opts.signal, + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw error + throw new RiseResourceError({ + status: 0, + method, + url, + message: sdkT('resourceApi.error.network'), + body: null, + }) + } + + return readResourceResponse(response, method, url) +} + +async function readResourceResponse(response: Response, method: string, url: string): Promise> { + if (response.status === 401 && typeof window !== 'undefined') { + const current = window.location.pathname + window.location.search + window.location.href = '/login?redirect=' + encodeURIComponent(current) + throw new RiseResourceError({ + status: 401, + method, + url, + message: sdkT('resourceApi.error.unauthorized'), + body: await parseBody(response).catch(() => null), + }) + } + + const body = await parseBody(response) + if (!response.ok) { + throw new RiseResourceError({ + status: response.status, + method, + url, + message: extractErrorMessage(body, response.status, response.statusText), + body, + }) + } + + return { data: body as T, status: response.status } +} + +export const scopedApi = { + get(path: string, scope: ResourceRequestScope, opts?: ResourceRequestOpts) { + return request('GET', path, scope, opts) + }, + post( + path: string, + scope: ResourceRequestScope, + body?: unknown, + opts?: Omit, + ) { + return request('POST', path, scope, { ...opts, body }) + }, + patch( + path: string, + scope: ResourceRequestScope, + body?: unknown, + opts?: Omit, + ) { + return request('PATCH', path, scope, { ...opts, body, contentType: 'application/merge-patch+json' }) + }, + delete(path: string, scope: ResourceRequestScope, opts?: ResourceRequestOpts) { + return request('DELETE', path, scope, opts) + }, +} diff --git a/web/packages/plugin-sdk/src/core.ts b/web/packages/plugin-sdk/src/core.ts new file mode 100644 index 0000000..48fa727 --- /dev/null +++ b/web/packages/plugin-sdk/src/core.ts @@ -0,0 +1,456 @@ +// @modelsphere/plugin-sdk — Core implementation (Story 10-2) + +import type { + PluginDefinition, + PluginInstance, + ApiClient, + PluginEventMap, + PluginEvents, + PluginNav, + PluginContext, + RiseHostSDK, + Scope, + ScopeType, +} from './types' +import { SCOPE_TYPES } from './types' +import { getHostSDK } from './host' + +/** SDK protocol version. The host exposes VERSION for compatibility checking. + * 1.0.0(freeland#110):对外发包起点,与宿主 __RISE_SDK__.VERSION 构成两端契约。 + * 1.0.1(theriseunion/project#67):桥接路径与宿主事件总线对齐,并对齐真实作用域 + * getScope 契约与 ScopeType 词汇。 */ +const SDK_VERSION = '1.0.1' + +// ── Helpers ───────────────────────────────────────────────── + +/** + * SDK 版本契约(freeland#110 能力 4):major 不合 = 契约不兼容,拒载并报明原因, + * 不再是仅告警 —— 半坏状态(菜单在、组件行为错)比不加载更难排查。 + * SDK 1.0 要求宿主 VERSION >= 1.0.0 且 major = 1;0.1.0 等旧宿主会直接拒载。 + * 宿主未声明 VERSION(旧宿主)时跳过,保持向后兼容。 + */ +function checkVersion(sdk: RiseHostSDK): void { + const hostVersion = sdk.VERSION + if (!hostVersion) return // host hasn't declared a version yet — fail open + // Strip a leading 'v' (e.g. 'v0.1.0', 'v0.1.0-beta') so it never parses to NaN. + const normalized = hostVersion.replace(/^v/i, '') + const [hostMajor] = normalized.split('.').map(Number) + const [sdkMajor] = SDK_VERSION.split('.').map(Number) + if (!Number.isFinite(hostMajor)) { + console.warn(`[modelsphere-plugin-sdk] 宿主 VERSION 不可解析(${hostVersion}),跳过兼容校验。`) + return + } + if (hostMajor !== sdkMajor) { + throw new Error( + `[modelsphere-plugin-sdk] Version mismatch: host ${hostVersion}, SDK ${SDK_VERSION}. ` + + '请用与宿主同 major 的 @modelsphere/plugin-sdk 重新构建该插件。' + ) + } +} + +/* + 注册契约的校验诊断 —— **不国际化**(国际化规范 §1:开发者日志、调试文本和内部诊断 + 不是默认的用户文案)。它抛给插件开发者看,只出现在构建期与浏览器控制台,不进界面; + 分类同 rise-global `2026-09-26-design-system-i18n-design.md` §4.4 豁免 1「开发期诊断」。 + 本文件其余 `'必须是字符串'` 之类的 reason 同理。 +*/ +function validationError(pluginName: string, path: string, reason: string): never { + throw new Error(`[modelsphere-plugin-sdk] plugin '${pluginName}': ${path} ${reason}`) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function validateRequiredString(pluginName: string, path: string, value: unknown): void { + if (value === undefined || value === '' || (typeof value === 'string' && value.trim() === '')) { + validationError(pluginName, path, '缺失') + } + if (typeof value !== 'string') validationError(pluginName, path, '必须是字符串') +} + +function validateOptionalString(pluginName: string, path: string, value: unknown): void { + if (value !== undefined && typeof value !== 'string') { + validationError(pluginName, path, '必须是字符串') + } +} + +function validateMenu(pluginName: string, path: string, menu: unknown): void { + if (!isRecord(menu)) validationError(pluginName, path, '必须是对象') + + validateRequiredString(pluginName, `${path}.name`, menu.name) + validateRequiredString(pluginName, `${path}.path`, menu.path) + validateRequiredString(pluginName, `${path}.scope`, menu.scope) + + for (const field of ['title', 'icon', 'parent', 'group', 'authKey'] as const) { + validateOptionalString(pluginName, `${path}.${field}`, menu[field]) + } + + if (menu.order !== undefined && (typeof menu.order !== 'number' || !Number.isFinite(menu.order))) { + validationError(pluginName, `${path}.order`, '必须是有限数字') + } + if (menu.hidden !== undefined && typeof menu.hidden !== 'boolean') { + validationError(pluginName, `${path}.hidden`, '必须是布尔值') + } + if (menu.requiredScope !== undefined) { + const validScopes: Scope['type'][] = [...SCOPE_TYPES] + if ( + !Array.isArray(menu.requiredScope) || + menu.requiredScope.some((scope) => !validScopes.includes(scope as Scope['type'])) + ) { + validationError(pluginName, `${path}.requiredScope`, '包含无效的 scope') + } + } + if (menu.children !== undefined) { + if (!Array.isArray(menu.children)) validationError(pluginName, `${path}.children`, '必须是数组') + menu.children.forEach((child, index) => validateMenu(pluginName, `${path}.children[${index}]`, child)) + } +} + +function validatePluginDefinition(def: unknown): void { + const pluginName = (isRecord(def) && typeof def.name === 'string' && def.name.trim() !== '') + ? def.name + : '' + if (!isRecord(def)) validationError(pluginName, 'definition', '必须是对象') + validateRequiredString(pluginName, 'name', def.name) + + if (def.menus !== undefined) { + if (!Array.isArray(def.menus)) validationError(pluginName, 'menus', '必须是数组') + def.menus.forEach((menu, index) => validateMenu(pluginName, `menus[${index}]`, menu)) + } + + if (def.scopes !== undefined) { + if (!Array.isArray(def.scopes)) validationError(pluginName, 'scopes', '必须是数组') + def.scopes.forEach((scope, index) => { + const path = `scopes[${index}]` + if (!isRecord(scope)) validationError(pluginName, path, '必须是对象') + validateRequiredString(pluginName, `${path}.scope`, scope.scope) + for (const field of ['title', 'icon', 'category'] as const) { + validateOptionalString(pluginName, `${path}.${field}`, scope[field]) + } + }) + } +} + +// ── Runtime capabilities ──────────────────────────────────── + +function notImplemented(module: string, method: string): never { + throw new Error( + `[modelsphere-plugin-sdk] ${module}.${method}() is not yet implemented (Phase 0). ` + + `This will be available in Phase 1.` + ) +} + +function createStubAPI(): ApiClient { + return { + get: () => notImplemented('api', 'get'), + post: () => notImplemented('api', 'post'), + patch: () => notImplemented('api', 'patch'), + delete: () => notImplemented('api', 'delete'), + } +} + +type EventName = keyof PluginEventMap +type StoredEventHandler = (data: unknown) => unknown + +/** + * State-like events keep a lastEmit snapshot that is replayed to new + * subscribers. Reserved future events are deliberately excluded until their + * payloads are finalised (T2/T3). + */ +const SNAPSHOT_EVENTS: ReadonlySet = new Set(['scope:change']) + +const eventHandlers = new Map>() +const lastEmits = new Map() + +/** + * Notify one subscriber with error isolation, returning the subscriber's + * result (possibly a promise). Covers both synchronous throws (try/catch) and + * rejected promises from async subscribers (attached .catch), so a broken + * handler never interrupts the others and never surfaces as an unhandled + * rejection — while the caller (e.g. the host bridge wrapper) can still + * observe the returned value. + */ +function invokeEventHandler(event: EventName, handler: StoredEventHandler, data: unknown): unknown { + try { + const result = handler(data) + if (isPromiseLike(result)) { + Promise.resolve(result).catch((error) => { + console.error(`[modelsphere-plugin-sdk] events handler error for '${event}':`, error) + }) + } + return result + } catch (error) { + console.error(`[modelsphere-plugin-sdk] events handler error for '${event}':`, error) + return undefined + } +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ) +} + +/** + * Locate an optional host event bus (console side, T2). Bridging is + * all-or-nothing: only when the host exposes on/once/emit together do SDK + * events bridge to it, so a partial bus (e.g. only on) can never swallow + * emits or subscriptions. Otherwise fall back to the in-SDK bus. + */ +function resolveHostBus(): RiseHostSDK | null { + try { + const sdk = getHostSDK() + return typeof sdk.on === 'function' && + typeof sdk.once === 'function' && + typeof sdk.emit === 'function' + ? sdk + : null + } catch { + return null // no host available — use the internal bus + } +} + +const FALLBACK_SCOPE: Scope = { + type: 'platform', + value: 'global', + full: 'platform/global', +} + +function readHostScope(): Scope | null { + try { + const payload = getHostSDK().getScope?.() + if (!isRecord(payload)) return null + + const type = payload.scope + const value = payload.value + const full = payload.full + if (typeof type !== 'string' || !SCOPE_TYPES.includes(type as ScopeType)) return null + if (typeof value !== 'string' || value.trim() === '') return null + if (typeof full !== 'string' || full.trim() === '') return null + if (full !== `${type}/${value}`) return null + + return { type: type as ScopeType, value, full } + } catch { + return null + } +} + +function getReplayPayload(event: EventName): { available: boolean; value?: unknown } { + if (!SNAPSHOT_EVENTS.has(event)) return { available: false } + if (lastEmits.has(event)) return { available: true, value: lastEmits.get(event) } + + // The only current snapshot event is scope:change. If another event is + // added to SNAPSHOT_EVENTS, confirm its replay payload shape here as well. + const scope = readHostScope() + if (scope) { + return { + available: true, + value: { scope: scope.type, value: scope.value, full: scope.full }, + } + } + return { available: false } +} + +function subscribeEvent( + pluginName: string, + event: Event, + handler: (data: PluginEventMap[Event]) => void, + once: boolean, +): () => void { + const replay = getReplayPayload(event) + + const bus = resolveHostBus() + if (bus) { + if (replay.available) { + invokeEventHandler(event, handler as StoredEventHandler, replay.value) + if (once) return () => {} // snapshot consumed the once subscription + } + + const hostFn = once ? bus.once : bus.on + if (hostFn) { + const wrapped: StoredEventHandler = (data) => { + if (SNAPSHOT_EVENTS.has(event)) lastEmits.set(event, data) + return invokeEventHandler(event, handler as StoredEventHandler, data) + } + const unsubscribe = hostFn.call(bus, event, wrapped, pluginName) + return typeof unsubscribe === 'function' ? unsubscribe : () => {} + } + } + + let handlers = eventHandlers.get(event) + if (!handlers) { + handlers = new Set() + eventHandlers.set(event, handlers) + } + + const storedHandler: StoredEventHandler = (data) => { + if (once) unsubscribe() + return handler(data as PluginEventMap[Event]) + } + const unsubscribe = (): void => { + handlers.delete(storedHandler) + if (handlers.size === 0) eventHandlers.delete(event) + } + + handlers.add(storedHandler) + if (replay.available) { + invokeEventHandler(event, storedHandler, replay.value) + } + return unsubscribe +} + +function createEvents(pluginName: string): PluginEvents { + return { + emit(event: Event, data: PluginEventMap[Event]): void { + // Record the snapshot locally (state-like events only) in every mode so + // replay is SDK-guaranteed and independent of the host implementation. + if (SNAPSHOT_EVENTS.has(event)) lastEmits.set(event, data) + + const bus = resolveHostBus() + if (bus && typeof bus.emit === 'function') { + bus.emit.call(bus, event, data) + return + } + + const handlers = eventHandlers.get(event) + if (!handlers) return + for (const handler of [...handlers]) invokeEventHandler(event, handler, data) + }, + on: (event, handler) => subscribeEvent(pluginName, event, handler, false), + once: (event, handler) => subscribeEvent(pluginName, event, handler, true), + } +} + +function createStubNav(): PluginNav { + return { + addMenu: () => notImplemented('nav', 'addMenu'), + removeMenu: () => notImplemented('nav', 'removeMenu'), + setBadge: () => notImplemented('nav', 'setBadge'), + } +} + +function createPluginContext(def: PluginDefinition): PluginContext { + const scope = readHostScope() ?? FALLBACK_SCOPE + return { + plugin: { name: def.name, version: def.version, trust: 'verified' }, + scope, + tenant: { id: '', name: '', displayName: '' }, + user: { id: '', name: '', roles: [] }, + activeScope: 'console', + locale: 'zh-CN', + theme: { mode: 'light', primaryColor: '#3B82F6', cssVariables: {} }, + hasPermission: () => true, + } +} + +// ── createPlugin ──────────────────────────────────────────── + +/** + * Create and register a Rise plugin. + * + * This is the single entry point for plugin development. It handles: + * - Menu registration + * - Component registration + * - Slot registration + * - Lifecycle hooks (onMount / onScopeChange / onUnmount) + * + * @example + * ```ts + * import { createPlugin } from '@modelsphere/plugin-sdk' + * + * const plugin = createPlugin({ + * name: 'hello-world', + * version: '1.0.0', + * displayName: 'Hello World', + * menus: [ + * { name: 'dashboard', title: 'Hello', path: '/rise.io/v1/hello-world/dashboard', + * scope: 'console', icon: 'smile', order: 500 }, + * ], + * components: { HelloWorldDashboard }, + * }) + * ``` + */ +function registerComponentsAndSlots(sdk: RiseHostSDK, def: PluginDefinition): void { + // ── Register components ── + if (def.components) { + for (const [componentName, component] of Object.entries(def.components)) { + sdk.registerPluginComponent(def.name, componentName, component) + } + } + + // ── Register slots ── + if (def.slots) { + for (const slot of def.slots) { + sdk.registerSlot({ + type: slot.type, + name: slot.name, + plugin: def.name, + target: slot.target, + order: slot.order ?? 999, + }) + } + } + +} + +function registerDefinition(sdk: RiseHostSDK, def: PluginDefinition): void { + const bridgedMenus = (def.menus || []).map((m) => ({ + name: m.name, + title: m.title, + path: m.path, + icon: m.icon || '', + scope: m.scope, + parent: m.parent, + group: m.group || '', + order: m.order ?? 999, + authKey: m.authKey || '', + hidden: m.hidden || false, + children: m.children || [], + })) + + sdk.registerPlugin({ + name: def.name, + version: def.version, + displayName: def.displayName, + icon: def.icon || '', + scope: '', // plugin-level scope left empty for child plugins + scopes: def.scopes || undefined, + menus: bridgedMenus, + }) + +} + +export function createPlugin(def: PluginDefinition): PluginInstance { + validatePluginDefinition(def) + const sdk = getHostSDK() + checkVersion(sdk) + + registerDefinition(sdk, def) + + registerComponentsAndSlots(sdk, def) + + if (def.routes && Object.keys(def.routes).length > 0) { + sdk.registerPluginRoutes(def.name, def.routes as Record) + } + + if (def.onMount) { + const ctx = createPluginContext(def) + try { + def.onMount(ctx) + } catch (err) { + console.error(`[modelsphere-plugin-sdk] ${def.name} onMount error:`, err) + } + } + + console.log(`[modelsphere-plugin-sdk] Plugin created: ${def.name} v${def.version}`) + + return { + name: def.name, + api: sdk.apiClient || createStubAPI(), + events: createEvents(def.name), + nav: createStubNav(), + } +} diff --git a/web/packages/plugin-sdk/src/create-instance.test.ts b/web/packages/plugin-sdk/src/create-instance.test.ts new file mode 100644 index 0000000..95072c9 --- /dev/null +++ b/web/packages/plugin-sdk/src/create-instance.test.ts @@ -0,0 +1,124 @@ +// 「从镜像仓库进入实例创建」URL 契约的单测:三类落点、完整引用与 tag 的 +// 一致性、来源/上下文参数的可选性,以及「没有预设参数时不存在任何副作用」。 +import { describe, expect, it } from 'vitest' + +import { + CREATE_INSTANCE_ROUTES, + buildCreateInstanceUrl, + clearCreateInstanceParams, + isValidCreateInstancePreset, + parseCreateInstancePreset, + splitImageRef, +} from './create-instance' + +const REF = '10.126.126.53:30016/team/app:v2' + +describe('splitImageRef', () => { + it('splits a full reference with a port in the registry host', () => { + expect(splitImageRef(REF)).toEqual({ + registry: '10.126.126.53:30016', + repository: 'team/app', + tag: 'v2', + }) + }) + + it('refuses a reference without host, path or tag', () => { + expect(splitImageRef('host/team/app')).toBeNull() + expect(splitImageRef('/team/app:v2')).toBeNull() + expect(splitImageRef('host/:v2')).toBeNull() + expect(splitImageRef('host/team/app:')).toBeNull() + }) +}) + +describe('buildCreateInstanceUrl', () => { + const preset = { + imageRef: REF, + tag: 'v2', + source: 'public' as const, + namespace: 'team', + clusterId: 'cluster-a', + } + + it('builds every target route with the complete reference and tag', () => { + for (const target of ['container', 'inference', 'application'] as const) { + const url = buildCreateInstanceUrl(target, preset) + expect(url.startsWith(`${CREATE_INSTANCE_ROUTES[target]}?`)).toBe(true) + const query = new URLSearchParams(url.slice(url.indexOf('?') + 1)) + expect(query.get('create')).toBe('1') + expect(query.get('imageRef')).toBe(REF) + expect(query.get('imageTag')).toBe('v2') + expect(query.get('imageSource')).toBe('public') + expect(query.get('namespace')).toBe('team') + expect(query.get('clusterId')).toBe('cluster-a') + } + }) + + it('omits provenance and context when they are absent', () => { + const url = buildCreateInstanceUrl('container', { + imageRef: REF, tag: 'v2', source: '', namespace: '', clusterId: '', + }) + const query = new URLSearchParams(url.slice(url.indexOf('?') + 1)) + expect(query.has('imageSource')).toBe(false) + expect(query.has('namespace')).toBe(false) + expect(query.has('clusterId')).toBe(false) + }) + + it('refuses a preset whose tag does not match the reference', () => { + expect(buildCreateInstanceUrl('container', { ...preset, tag: 'v1' })).toBe('') + expect(buildCreateInstanceUrl('container', { ...preset, imageRef: 'team/app' })).toBe('') + }) +}) + +describe('parseCreateInstancePreset', () => { + it('round-trips a built URL', () => { + const url = buildCreateInstanceUrl('inference', { + imageRef: REF, tag: 'v2', source: 'hosted', namespace: 'team', clusterId: '', + }) + expect(parseCreateInstancePreset(url.slice(url.indexOf('?')))).toEqual({ + imageRef: REF, + tag: 'v2', + source: 'hosted', + namespace: 'team', + clusterId: '', + }) + }) + + it('treats absent, incomplete or inconsistent parameters as no preset', () => { + expect(parseCreateInstancePreset('')).toBeNull() + expect(parseCreateInstancePreset('?namespace=team')).toBeNull() + expect(parseCreateInstancePreset(`?create=1&imageRef=${encodeURIComponent(REF)}`)).toBeNull() + expect(parseCreateInstancePreset(`?create=1&imageRef=${encodeURIComponent(REF)}&imageTag=v1`)).toBeNull() + expect(parseCreateInstancePreset('?create=0&imageRef=x%2Fy%3Az&imageTag=z')).toBeNull() + }) + + it('keeps an unknown provenance from invalidating the preset', () => { + const parsed = parseCreateInstancePreset(`?create=1&imageRef=${encodeURIComponent(REF)}&imageTag=v2&imageSource=mystery`) + expect(parsed).not.toBeNull() + expect(parsed!.source).toBe('') + }) +}) + +describe('isValidCreateInstancePreset', () => { + it('only accepts a complete reference with the matching tag', () => { + expect(isValidCreateInstancePreset({ imageRef: REF, tag: 'v2' })).toBe(true) + expect(isValidCreateInstancePreset({ imageRef: REF, tag: 'v1' })).toBe(false) + expect(isValidCreateInstancePreset({ imageRef: '', tag: '' })).toBe(false) + }) +}) + +describe('clearCreateInstanceParams', () => { + it('removes only the contract parameters and leaves page parameters alone', () => { + const url = new URL('https://console.example/core.rise.io/v1/tenant/deployments?namespace=team&page=2') + expect(clearCreateInstanceParams(url)).toBe(false) + expect(url.searchParams.get('namespace')).toBe('team') + const presetUrl = new URL(buildCreateInstanceUrl('container', { + imageRef: REF, tag: 'v2', source: 'public', namespace: 'team', clusterId: '', + }), 'https://console.example') + presetUrl.searchParams.set('page', '2') + expect(clearCreateInstanceParams(presetUrl)).toBe(true) + expect(presetUrl.searchParams.get('page')).toBe('2') + expect(presetUrl.searchParams.get('namespace')).toBe('team') + expect(presetUrl.searchParams.has('create')).toBe(false) + expect(presetUrl.searchParams.has('imageRef')).toBe(false) + }) +}) diff --git a/web/packages/plugin-sdk/src/create-instance.ts b/web/packages/plugin-sdk/src/create-instance.ts new file mode 100644 index 0000000..7da1759 --- /dev/null +++ b/web/packages/plugin-sdk/src/create-instance.ts @@ -0,0 +1,144 @@ +// 「从镜像仓库进入实例创建」的 URL 契约。 +// +// 镜像页与三类创建表单分属不同插件(registry / model-x / custom-service, +// 容器实例在框架 Console),插件之间不能 import 源码,所以只经 URL 交接: +// +// ?create=1&imageRef=&imageTag= +// &imageSource=public|hosted|external +// &namespace=&clusterId= +// +// 规则(两侧都按这一份实现,单测锁死): +// - `create=1` 是开关;缺席时创建表单的行为与没有这套契约时完全一致。 +// - `imageRef` 是完整引用(含 tag),`imageTag` 必须与引用末尾的 tag 逐字相同。 +// 任一缺失、为空、或两者不一致,整份预设作废 —— 宁可没有预选,也不塞半截引用。 +// - `imageSource` 只用于说明来源(public / hosted / external),不改变任何取数或 +// 版本规则;缺失或未知按空串处理,预设仍然有效。 +// - `namespace` / `clusterId` 是可选的目标上下文。调用方只能把它们当「建议」: +// 创建页仍须按自己当前的集群与项目清单校验,不能把预设值直接当成已存在。 +// - 参数用 URLSearchParams 编码;读取方消费一次后从地址栏擦掉,避免刷新重开。 + +export type CreateInstanceTarget = 'container' | 'inference' | 'application' + +export type CreateInstanceImageSource = 'public' | 'hosted' | 'external' + +export interface CreateInstancePreset { + /** 完整镜像引用:`host/path:tag` */ + imageRef: string + /** 引用末尾的 tag */ + tag: string + /** 来源说明;缺失或未知为空串 */ + source: CreateInstanceImageSource | '' + /** 目标项目(建议值,创建页须自行校验) */ + namespace: string + /** 目标集群(建议值,创建页须自行校验) */ + clusterId: string +} + +/** 三类创建表单各自的落点。路径由宿主 navigate 自动补当前 scope 前缀。 */ +export const CREATE_INSTANCE_ROUTES: Readonly> = { + container: '/core.rise.io/v1/tenant/deployments', + inference: '/rise.io/v1/model-x/inferences', + application: '/rise.io/v1/custom-service/services', +} + +export const CREATE_INSTANCE_PARAM = 'create' +export const CREATE_INSTANCE_IMAGE_REF_PARAM = 'imageRef' +export const CREATE_INSTANCE_IMAGE_TAG_PARAM = 'imageTag' +export const CREATE_INSTANCE_IMAGE_SOURCE_PARAM = 'imageSource' +export const CREATE_INSTANCE_NAMESPACE_PARAM = 'namespace' +export const CREATE_INSTANCE_CLUSTER_PARAM = 'clusterId' + +const SOURCES: readonly CreateInstanceImageSource[] = ['public', 'hosted', 'external'] + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +/** + * 拆开 `host[:port]/path:tag`。必须同时有主机、路径与 tag:任何一个空缺都返回 null, + * 调用方据此判定「这还不是一个真能 pull 的引用」。 + */ +export function splitImageRef(ref: string): { registry: string; repository: string; tag: string } | null { + const value = text(ref) + if (!value) return null + const slash = value.indexOf('/') + if (slash <= 0 || slash === value.length - 1) return null + const registry = value.slice(0, slash) + const rest = value.slice(slash + 1) + const colon = rest.lastIndexOf(':') + if (colon <= 0 || colon === rest.length - 1) return null + const repository = rest.slice(0, colon) + const tag = rest.slice(colon + 1) + if (!repository || !tag || tag.includes('/') || /\s/.test(value)) return null + return { registry, repository, tag } +} + +/** 预设是否自洽:完整引用,且 `imageTag` 与引用末尾的 tag 一致。 */ +export function isValidCreateInstancePreset(preset: Pick): boolean { + const parts = splitImageRef(preset.imageRef) + const tag = text(preset.tag) + return !!parts && !!tag && parts.tag === tag +} + +function sourceOf(value: string): CreateInstanceImageSource | '' { + return (SOURCES as readonly string[]).includes(value) ? (value as CreateInstanceImageSource) : '' +} + +/** + * 三类入口的目标地址。预设不完整时返回空串:调用方据此禁用入口,而不是跳到 + * 一个没有预选的创建页。 + */ +export function buildCreateInstanceUrl(target: CreateInstanceTarget, preset: CreateInstancePreset): string { + const route = CREATE_INSTANCE_ROUTES[target] + if (!route) return '' + if (!isValidCreateInstancePreset(preset)) return '' + const params = new URLSearchParams() + params.set(CREATE_INSTANCE_PARAM, '1') + params.set(CREATE_INSTANCE_IMAGE_REF_PARAM, text(preset.imageRef)) + params.set(CREATE_INSTANCE_IMAGE_TAG_PARAM, text(preset.tag)) + const source = sourceOf(text(preset.source)) + if (source) params.set(CREATE_INSTANCE_IMAGE_SOURCE_PARAM, source) + const namespace = text(preset.namespace) + if (namespace) params.set(CREATE_INSTANCE_NAMESPACE_PARAM, namespace) + const clusterId = text(preset.clusterId) + if (clusterId) params.set(CREATE_INSTANCE_CLUSTER_PARAM, clusterId) + return `${route}?${params.toString()}` +} + +/** + * 创建页读取预设。缺席或残缺一律 null —— 这与「没有这套契约」是同一条路径, + * 调用方不需要额外的开关。 + */ +export function parseCreateInstancePreset(search: string): CreateInstancePreset | null { + const params = new URLSearchParams(search || '') + if (params.get(CREATE_INSTANCE_PARAM) !== '1') return null + const imageRef = text(params.get(CREATE_INSTANCE_IMAGE_REF_PARAM)) + const tag = text(params.get(CREATE_INSTANCE_IMAGE_TAG_PARAM)) + if (!isValidCreateInstancePreset({ imageRef, tag })) return null + return { + imageRef, + tag, + source: sourceOf(text(params.get(CREATE_INSTANCE_IMAGE_SOURCE_PARAM))), + namespace: text(params.get(CREATE_INSTANCE_NAMESPACE_PARAM)), + clusterId: text(params.get(CREATE_INSTANCE_CLUSTER_PARAM)), + } +} + +/** + * 消费一次后擦掉契约参数(保留页面自己的其它查询参数与历史记录语义)。 + * 返回是否真的擦掉了东西,调用方可据此决定要不要 replaceState。 + */ +export function clearCreateInstanceParams(url: URL): boolean { + // `namespace` / `clusterId` 也是页面自己的查询参数,擦掉会改变页面的筛选状态; + // 只擦这条契约独占的四个参数。开关不在时什么都不做 —— 页面原有的查询串不受影响。 + if (url.searchParams.get(CREATE_INSTANCE_PARAM) !== '1') return false + for (const name of [ + CREATE_INSTANCE_PARAM, + CREATE_INSTANCE_IMAGE_REF_PARAM, + CREATE_INSTANCE_IMAGE_TAG_PARAM, + CREATE_INSTANCE_IMAGE_SOURCE_PARAM, + ]) { + url.searchParams.delete(name) + } + return true +} diff --git a/web/packages/plugin-sdk/src/dev-shared.ts b/web/packages/plugin-sdk/src/dev-shared.ts new file mode 100644 index 0000000..c48b223 --- /dev/null +++ b/web/packages/plugin-sdk/src/dev-shared.ts @@ -0,0 +1,39 @@ +import path from 'node:path' +import fs from 'node:fs' +import ts from 'typescript' +import type { Plugin } from 'vite' +import { HOST_SURFACES, sharedNames } from './shared-imports' + +export function developmentSharedModules(root: string, directory: string) { + const source = path.join(root, 'src') + const files = Object.fromEntries(Object.keys(HOST_SURFACES).map(name => [name, path.join(directory, name.split('/').join('-') + '.mjs')])) + function refresh() { + const names = sharedNames(source, ts) + const changed: string[] = [] + for (const [name, surface] of Object.entries(HOST_SURFACES)) { + const exports = [...names[name]].sort().map(key => `export const ${key}=shared.${key};`).join('\n') + const contents = `const shared=(window.__MODELSPHERE_SDK__ || window.__GLOBAL_SDK__ || window.__RISE_SDK__).${surface};\n${exports}\n` + if (!fs.existsSync(files[name]) || fs.readFileSync(files[name], 'utf8') !== contents) { + fs.writeFileSync(files[name], contents) + changed.push(files[name]) + } + } + return changed + } + refresh() + const plugin: Plugin = { + name: 'modelsphere-development-shared-modules', + handleHotUpdate(context) { + if (!context.file.startsWith(source + path.sep) || !/\.tsx?$/.test(context.file)) return + for (const file of refresh()) { + for (const module of context.server.moduleGraph.getModulesByFile(file) ?? []) { + context.server.moduleGraph.invalidateModule(module, new Set(), context.timestamp, true) + } + } + // Page imports get fresh shim URLs; shared exports still refer to host singletons. + return context.modules + }, + } + const aliases = Object.entries(files).map(([name, replacement]) => ({ find: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), replacement })) + return { aliases, plugin } +} diff --git a/web/packages/plugin-sdk/src/dev.ts b/web/packages/plugin-sdk/src/dev.ts new file mode 100644 index 0000000..adec2e0 --- /dev/null +++ b/web/packages/plugin-sdk/src/dev.ts @@ -0,0 +1,72 @@ +import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { defineConfig, type Plugin } from 'vite' +import react from '@vitejs/plugin-react' +import tailwind from '@tailwindcss/vite' +import { HOST_SURFACES } from './shared-imports' +import { readChartVersion } from './build' +import { developmentSharedModules } from './dev-shared' + +export interface PluginDevOptions { + name: string + hostSource: string + hostOrigin: string + root?: string + entry?: string + host?: string + port?: number +} + +function developmentEntry(entry: string, stylesheet: string, hostOrigin: string): Plugin { + const id = '\0modelsphere-plugin-entry' + return { + name: 'modelsphere-plugin-development-entry', + resolveId(source) { if (source.endsWith('/__modelsphere_entry.ts') || source === '__modelsphere_entry.ts') return id }, + load(source) { + if (source !== id) return + return `import RefreshRuntime from ${JSON.stringify(hostOrigin + '/@react-refresh')}; +if (!window.__vite_plugin_react_preamble_installed__) RefreshRuntime.injectIntoGlobalHook(window); +window.$RefreshReg$ = () => {}; +window.$RefreshSig$ = () => (type) => type; +window.__vite_plugin_react_preamble_installed__ = true; +await import("/@vite/client"); +await import(${JSON.stringify('/@fs' + stylesheet)}); +await import(${JSON.stringify('/' + entry)}); +if (import.meta.hot) import.meta.hot.accept(${JSON.stringify('/' + entry)}, () => window.location.reload());` + }, + } +} + +/** Serve source modules and HMR through the configured Console plugin proxy. */ +export function definePluginDevConfig(options: PluginDevOptions) { + if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(options.name)) throw new Error('Plugin name must be lowercase kebab-case') + const origin = new URL(options.hostOrigin) + if (!['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new Error('hostOrigin must be a Console HTTP(S) origin') + const root = path.resolve(options.root ?? process.cwd()) + const host = fs.realpathSync(path.resolve(options.hostSource, 'web')) + const base = `/plugins/${options.name}/` + const generated = path.join(root, '.modelsphere', 'dev') + fs.mkdirSync(generated, { recursive: true }) + const shared = developmentSharedModules(root, generated) + const stylesheet = path.join(generated, 'style.css') + fs.writeFileSync(stylesheet, `@import ${JSON.stringify(path.join(host, 'src/index.css'))};\n@source ${JSON.stringify(path.join(root, 'src'))};\n`) + const sdk = path.dirname(fileURLToPath(import.meta.url)) + return defineConfig({ + root, base, appType: 'custom', + define: { __PLUGIN_VERSION__: JSON.stringify(readChartVersion(root, options.name)) }, + plugins: [shared.plugin, developmentEntry(options.entry ?? 'src/runtime.tsx', stylesheet, origin.origin), react({ reactRefreshHost: origin.origin }), tailwind()], + resolve: { alias: [ + { find: /^react\/jsx-(?:dev-)?runtime$/, replacement: path.join(sdk, 'react-jsx-runtime-shim.mjs') }, + { find: /^(react|react-dom)$/, replacement: path.join(sdk, 'react-shim.mjs') }, + ...shared.aliases, + ] }, + optimizeDeps: { exclude: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', '@modelsphere/plugin-sdk', ...Object.keys(HOST_SURFACES)] }, + server: { + host: options.host ?? '127.0.0.1', port: options.port ?? 4181, strictPort: true, + cors: { origin: origin.origin }, allowedHosts: [origin.hostname], + hmr: { host: origin.hostname, protocol: origin.protocol === 'https:' ? 'wss' : 'ws', clientPort: Number(origin.port || (origin.protocol === 'https:' ? 443 : 80)), path: '__hmr' }, + fs: { allow: [root, host, path.resolve(sdk, '..')] }, + }, + }) +} diff --git a/web/packages/plugin-sdk/src/host-global.ts b/web/packages/plugin-sdk/src/host-global.ts new file mode 100644 index 0000000..cedf667 --- /dev/null +++ b/web/packages/plugin-sdk/src/host-global.ts @@ -0,0 +1,11 @@ +import type { RiseHostSDK } from "./types"; + +/** Read the canonical host, retaining existing Global bundle compatibility. */ +export function readHostSDK(): RiseHostSDK | undefined { + const scope = globalThis as typeof globalThis & { + __MODELSPHERE_SDK__?: RiseHostSDK; + __GLOBAL_SDK__?: RiseHostSDK; + __RISE_SDK__?: RiseHostSDK; + }; + return scope.__MODELSPHERE_SDK__ ?? scope.__GLOBAL_SDK__ ?? scope.__RISE_SDK__; +} diff --git a/web/packages/plugin-sdk/src/host.ts b/web/packages/plugin-sdk/src/host.ts new file mode 100644 index 0000000..9feb3cc --- /dev/null +++ b/web/packages/plugin-sdk/src/host.ts @@ -0,0 +1,121 @@ +import { readHostSDK } from "./host-global"; +// @modelsphere/plugin-sdk — Typed accessors for host SDK capabilities +// +// These functions provide type-safe access to `window.__RISE_SDK__` properties. +// They read from the host at call time (not import time), so they work correctly +// even when the host populates the SDK asynchronously. + +import type { RiseHostSDK, ApiClient, AuthAPI, BusinessSurface, PermissionsAPI, ScopeContextValue, UiSurface } from './types' + +/** Get the raw host SDK. Throws if not running inside Rise Console. */ +export function getHostSDK(): RiseHostSDK { + const sdk = readHostSDK() + if (!sdk) { + throw new Error( + '[modelsphere-plugin-sdk] window.__RISE_SDK__ not found. ' + + 'Is this running inside ModelSphere Console?' + ) + } + return sdk +} + +/** Get the pre-configured API client. Handles auth (cookie) and 401 redirect. */ +export function getApi(): ApiClient { + return getHostSDK().apiClient +} + +/** Client-side navigation with auto scope prefix. */ +export function navigate(url: string): void { + getHostSDK().navigate(url) +} + +/** 宿主未就绪(单测、SSR、宿主外加载)时返回空对象:插件在模块顶层建句柄,导入阶段不能抛错。 */ +function hostSurface(name: 'ui' | 'business'): Record { + return (readHostSDK()?.[name] ?? {}) as Record +} + +/** + * 取宿主注入面 `window.__RISE_SDK__.ui`(`@theriseunion/ui`)。插件只在 `src/lib/ui.ts` 调一次: + * + * ```ts + * import type * as UI from '@theriseunion/ui' + * export const ui = getUi() + * ``` + * + * 类型参数在编译期擦除,产物不变;不传时为 `any`(没有同级 clone rise-global 的独立插件仓)。 + * 宿主在加载插件 UMD 之前已挂好注入面,模块顶层调用是安全的。 + */ +export function getUi(): T { + return hostSurface('ui') as T +} + +/** + * 取宿主注入面 `window.__RISE_SDK__.business`(`@theriseunion/business`)。插件只在 `src/lib/business.ts` 调一次: + * + * ```ts + * import type * as Business from '@theriseunion/business' + * export const business = getBusiness() + * ``` + */ +export function getBusiness(): T { + return hostSurface('business') as T +} + +/** + * 宿主权限判定 hook(React hook,只能在组件内调用)。转发宿主 `PermissionContext`,权限到达自动重渲染; + * 判权用它,不要用 `getAuth().hasPermission`(命令式读取拿不到「权限到了」的信号)。 + */ +export function usePermissions(): PermissionsAPI { + const hook = readHostSDK()?.usePermissions + if (typeof hook !== 'function') { + throw new Error('[modelsphere-plugin-sdk] usePermissions is not provided by the host. Requires a Rise console with SDK.ui / SDK.business (rise-global#1317 or later).') + } + return hook() as PermissionsAPI +} + +/** Get current scope context (scope, cluster, workspace, project). */ +export function getScopeContext(): ScopeContextValue { + return getHostSDK().ScopeContext +} + +/** Get the auth namespace. Provides current user info and permission checks. */ +export function getAuth(): AuthAPI { + return getHostSDK().get('auth') +} + +/** + * Get every component a plugin has registered, keyed by component name. + * Used by business plugins to embed shared SDK widgets (Story 7.4 — + * compute-core exposes QuotaPreflight this way). Returns an empty bag if + * the plugin isn't loaded yet — callers should re-read the bag inside a + * useEffect, not at module top level. + */ +export function getPluginComponents = Record>( + pluginName: string, +): T { + const fn = getHostSDK().getPluginComponents + if (typeof fn !== 'function') return {} as T + return fn(pluginName) as T +} + +/** + * Generate a UUID v4. + * crypto.randomUUID() requires a Secure Context (HTTPS). + * This function falls back to crypto.getRandomValues() which works on HTTP. + */ +export function generateUUID(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + // Fallback using crypto.getRandomValues (available in all browsers, including HTTP) + const bytes = new Uint8Array(16) + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes) + } else { + for (let i = 0; i < 16; i++) bytes[i] = (Math.random() * 256) | 0 + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant 1 + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/web/packages/plugin-sdk/src/i18n.test.ts b/web/packages/plugin-sdk/src/i18n.test.ts new file mode 100644 index 0000000..aa53076 --- /dev/null +++ b/web/packages/plugin-sdk/src/i18n.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import enUS from './locales/sdk.en-US' +import zhCN from './locales/sdk.zh-CN' +import { SDK_I18N_NS, renderFallback, resetSdkI18nForTest, sdkKey, sdkT, useSdkT } from './i18n' + +function setHost(sdk: unknown): void { + ;(globalThis as any).__RISE_SDK__ = sdk +} + +afterEach(() => { + delete (globalThis as any).__RISE_SDK__ + resetSdkI18nForTest() +}) + +/** 展平成 `a.b.c` → 字符串,用来比两侧词表的键集合。 */ +function flatten(obj: unknown, prefix = '', out: Record = {}): Record { + for (const [k, v] of Object.entries(obj as Record)) { + const key = prefix ? `${prefix}.${k}` : k + if (typeof v === 'string') out[key] = v + else flatten(v, key, out) + } + return out +} + +describe('SDK 词表', () => { + it('中英键集合一致', () => { + expect(Object.keys(flatten(enUS)).sort()).toEqual(Object.keys(flatten(zhCN)).sort()) + }) + + it('两侧插值参数一一对应', () => { + const zh = flatten(zhCN) + const en = flatten(enUS) + for (const key of Object.keys(zh)) { + const vars = (s: string) => (s.match(/\{(\w+)\}/g) ?? []).sort() + expect(vars(en[key]), key).toEqual(vars(zh[key])) + } + }) + + /* + 花括号里只能是 ICU 参数名。宿主是 i18next-icu,单花括号即参数 —— 往里塞 JSX、代码片段 + 或中文,整条会 MALFORMED_ARGUMENT、在页面上渲染成 key 原文(freeland#1493 踩过 96 条)。 + 这条不靠注释约束,靠这里拦。 + */ + it('花括号里只有参数名', () => { + for (const [key, value] of Object.entries({ ...flatten(zhCN), ...flatten(enUS) })) { + for (const seg of value.match(/\{[^}]*\}/g) ?? []) { + expect(seg, `${key}: ${seg}`).toMatch(/^\{\w+\}$/) + } + } + }) + + it('花括号成对', () => { + for (const [key, value] of Object.entries({ ...flatten(zhCN), ...flatten(enUS) })) { + expect((value.match(/\{/g) ?? []).length, key).toBe((value.match(/\}/g) ?? []).length) + } + }) + + it('英文侧不残留中文', () => { + for (const [key, value] of Object.entries(flatten(enUS))) { + expect(value, key).not.toMatch(/[一-鿿]/) + } + }) +}) + +describe('没有宿主时', () => { + it('回落中文词表,不抛', () => { + expect(sdkT('resourceApi.error.network')).toBe('网络连接失败,请检查网络设置。') + }) + + it('回落路径也做插值', () => { + expect(renderFallback('加载失败:{reason}', { reason: 'HTTP 500' })).toBe('加载失败:HTTP 500') + }) + + it('查不到的 key 原样返回,不抛', () => { + expect(sdkT('resourceApi.nope')).toBe('resourceApi.nope') + }) + + /* + spec §4.2 约束 3:未注入宿主时除了插值,还要按 ICU 规则还原引号转义。 + 不还原的话中文界面在单测 / SSR 路径下会把转义符原样印出来。 + */ + it("'{…}' 还原成字面花括号", () => { + expect(renderFallback("格式应为 '{项目名}'/'{制品名}'")).toBe('格式应为 {项目名}/{制品名}') + }) + + it("'' 还原成单撇号", () => { + expect(renderFallback("the caller''s value")).toBe("the caller's value") + }) + + it('转义后的花括号不被当成插值', () => { + expect(renderFallback("留 '{name}' 原样,替换 {name}", { name: 'x' })).toBe('留 {name} 原样,替换 x') + }) +}) + +describe('有宿主时', () => { + it('注册到保留 ns,且只注册一次', () => { + const registerI18n = vi.fn() + const t = vi.fn(() => 'translated') + setHost({ registerI18n, getPluginT: () => t }) + + sdkT('resourceApi.error.network') + sdkT('resourceApi.error.unauthorized') + + expect(registerI18n).toHaveBeenCalledTimes(1) + expect(registerI18n.mock.calls[0][0]).toBe(SDK_I18N_NS) + expect(Object.keys(registerI18n.mock.calls[0][1])).toEqual(['zh-CN', 'en-US']) + }) + + it('取词走宿主,不走回落', () => { + const t = vi.fn(() => 'All clusters') + setHost({ registerI18n: vi.fn(), getPluginT: () => t }) + + expect(sdkT('resourceApi.error.network')).toBe('All clusters') + expect(t).toHaveBeenCalledWith('resourceApi.error.network', undefined) + }) + + it('useSdkT 订阅语言变化', () => { + const useLocale = vi.fn(() => 'en-US') + setHost({ registerI18n: vi.fn(), getPluginT: () => (k: string) => k, useLocale }) + + useSdkT() + + expect(useLocale).toHaveBeenCalled() + }) + + it('宿主只挂了一半(没有 registerI18n)也不抛', () => { + setHost({}) + expect(sdkT('resourceApi.error.unauthorized')).toBe('未授权,请重新登录。') + }) +}) + +describe('sdkKey', () => { + /* spec §4.2 约束 4:模块级表只存 key,渲染时再取词;sdkKey 是恒等函数,只做标记。 */ + it('原样返回,不取词', () => { + expect(sdkKey('workspaceSelect.all')).toBe('workspaceSelect.all') + }) +}) diff --git a/web/packages/plugin-sdk/src/i18n.ts b/web/packages/plugin-sdk/src/i18n.ts new file mode 100644 index 0000000..1cb394c --- /dev/null +++ b/web/packages/plugin-sdk/src/i18n.ts @@ -0,0 +1,159 @@ +import { readHostSDK } from "./host-global"; +// SDK 自己的文案通道。 +// +// ── 为什么 SDK 需要一条自己的通道 ──────────────────────────────────────────── +// 业务组件(SpecSelect / ImageSelect / 三个作用域选择器…)住在这个包里,不属于任何插件, +// 拿不到插件的 namespace。之前的做法是把中文写死在组件里 —— `packages/**` 不在写死中文 +// 棘轮的扫描面,所以一直没被拦住,英文界面下原样显示中文。 +// +// 做法照 `@theriseunion/ui`:词表随包走,注册到一个保留 ns,取词经宿主同一个 i18next 实例 +// (宿主侧对应的是 `console/src/i18n/engine.ts` 末尾的 `registerI18n('ui', uiLocales)`)。 +// 区别是本包被打进每个插件的产物、不由宿主加载,所以注册由本包自己做,宿主零改动。 +// +// ── 注册是幂等的 ──────────────────────────────────────────────────────────── +// 每个插件的产物里都有一份本模块,各自注册一次。宿主的 `registerI18n` 落到 +// `addResourceBundle(lng, ns, res, /*deep*/true, /*overwrite*/true)`,重复注册同一份是合并, +// 无副作用。 +// +// ── 没有宿主时不能抛 ──────────────────────────────────────────────────────── +// 单测与 SSR 里 `window.__RISE_SDK__` 不存在。那时回落到中文词表 + 最小插值,行为与 +// design 的 `i18n/store.ts` 未注入适配器时一致:能渲染出中文,不抛。 + +import zhCN from './locales/sdk.zh-CN' +import enUS from './locales/sdk.en-US' + +/** 本包在宿主 i18next 里的保留 namespace。 */ +export const SDK_I18N_NS = 'rise-sdk' + +export type SdkTranslate = (key: string, params?: Record) => string + +/** + * 模块级表里的 key 用它标记。恒等函数,只为读代码时一眼看出「这是 key、不是文案」。 + * + * 取词**不得在模块顶层求值** —— 那时语言还没定,值会被永久钉在 zh-CN。 + * 模块级表只存 key,渲染时再用 `t` 解析(spec §4.2 约束 4): + * + * const QUOTA_SCOPE = { project: sdkKey('specSelect.quota.project') } // ✅ + * const QUOTA_SCOPE = { project: sdkT('specSelect.quota.project') } // ❌ 顶层求值 + */ +export function sdkKey(key: K): K { + return key +} + +type HostLike = { + registerI18n?: (ns: string, res: Record>) => void + getPluginT?: (ns: string) => SdkTranslate + useLocale?: () => string + React?: { useMemo: (factory: () => T, deps: unknown[]) => T } +} + +function host(): HostLike | null { + return (readHostSDK() as HostLike | undefined) ?? null +} + +let registered = false + +/** + * 把本包的词表注册进宿主。首次取词时调用,不在模块顶层执行 —— 插件产物加载时宿主 SDK + * 可能还没挂上(`host.ts` 的注释:读宿主一律在调用时,不在 import 时)。 + */ +export function ensureSdkI18nRegistered(): void { + if (registered) return + const h = host() + if (!h?.registerI18n) return + h.registerI18n(SDK_I18N_NS, { 'zh-CN': zhCN as unknown as Record, 'en-US': enUS as unknown as Record }) + registered = true +} + +/** + * 仅供单测:按中文词表解析一个 key。装了假宿主的组件测试拿它当 `getPluginT` 的实现, + * 于是那些断言验的是真词表渲染出来的中文,不是 key 原文(design spec §8.2 第 1 项)。 + */ +export function sdkFallbackT(key: string, params?: Record): string { + return renderFallback(readFallback(key) ?? key, params) +} + +/** 仅供单测:清掉「已注册」标记。 */ +export function resetSdkI18nForTest(): void { + registered = false +} + +function readFallback(key: string): string | undefined { + let node: unknown = zhCN + for (const seg of key.split('.')) { + if (typeof node !== 'object' || node === null) return undefined + node = (node as Record)[seg] + } + return typeof node === 'string' ? node : undefined +} + +/** + * 未注入宿主时的最小渲染,做两件事(与 design 的 `i18n/store.ts` 同口径, + * 见 rise-global spec `2026-09-26-design-system-i18n-design.md` §4.2 约束 3): + * + * 1. 按 `{name}` 替换插值; + * 2. 按 ICU 规则还原引号转义 —— `''` → `'`,`'{…}'` → `{…}`。 + * + * 不做第 2 件的话,中文界面在无宿主路径(单测 / SSR)下会把转义符原样印出来。 + * 复数与 select 交给宿主的 ICU,本函数不处理。 + */ +export function renderFallback(text: string, params?: Record): string { + let out = '' + let i = 0 + while (i < text.length) { + const c = text[i] + if (c === "'") { + // '' → 一个字面撇号 + if (text[i + 1] === "'") { out += "'"; i += 2; continue } + // '{…}' → 花括号原样输出,且**不参与插值**(先还原再插值会把它也替换掉) + const close = text.indexOf("'", i + 1) + if (close > i && /[{}]/.test(text.slice(i + 1, close))) { + out += text.slice(i + 1, close) + i = close + 1 + continue + } + out += c + i += 1 + continue + } + if (c === '{' && params) { + const close = text.indexOf('}', i) + const name = close > i ? text.slice(i + 1, close) : '' + if (close > i && /^\w+$/.test(name)) { + out += Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : text.slice(i, close + 1) + i = close + 1 + continue + } + } + out += c + i += 1 + } + return out +} + +/** + * 取词函数。宿主在就走宿主的 i18next(跟随当前语言、走 ICU);不在就回落中文词表。 + * + * 非组件代码(校验、纯函数)直接调它;组件里用 `useSdkT()`,那个会订阅语言变化。 + */ +export function sdkT(key: string, params?: Record): string { + ensureSdkI18nRegistered() + const h = host() + if (h?.getPluginT) return h.getPluginT(SDK_I18N_NS)(key, params) + return renderFallback(readFallback(key) ?? key, params) +} + +/** + * 组件里的取词 hook:订阅语言变化,切语言即重渲染。 + * + * 返回的函数身份跟着语言走(宿主 `getPluginT` 按 `${locale}:${ns}` 缓存),所以放进 + * `useMemo` / `useCallback` 的依赖里是安全的 —— 这正是宿主那层缓存存在的原因。 + */ +export function useSdkT(): SdkTranslate { + const h = host() + // 订阅语言:没有宿主(单测 / SSR)时跳过,回落路径不需要重渲染。 + h?.useLocale?.() + ensureSdkI18nRegistered() + if (h?.getPluginT) return h.getPluginT(SDK_I18N_NS) + return sdkT +} diff --git a/web/packages/plugin-sdk/src/index.ts b/web/packages/plugin-sdk/src/index.ts new file mode 100644 index 0000000..6c2feb2 --- /dev/null +++ b/web/packages/plugin-sdk/src/index.ts @@ -0,0 +1,81 @@ +// @modelsphere/plugin-sdk — Main entry point + +// Re-export all types +export type { + ScopeType, + Scope, + ViewMode, + PluginMenu, + PluginContext, + PluginAPI, + RequestOpts, + PluginEventMap, + PluginEvents, + PluginNav, + SlotRegistration, + PluginDefinition, + PluginInstance, + RiseHostSDK, + ApiClient, + ApiResponse, + ScopeContextValue, + UiSurface, + BusinessSurface, + PermissionsAPI, + AuthUser, + AuthAPI, + ScopeFilterKind, + ScopeFilterOption, + UseScopeFilterOptions, + UseScopeFilterResult, +} from './types' + +// Re-export runtime API +export { createPlugin } from './core' + +// Re-export typed host accessors +export { getHostSDK, getApi, navigate, getUi, getBusiness, usePermissions, getScopeContext, getAuth, generateUUID, getPluginComponents } from './host' + +// Re-export page-level scope filter hook +export { useScopeFilter } from './scope-filter' +export { useClusterDisplayName } from './cluster-display-name' + +export { + RESOURCES_FACADE_PREFIX, + RiseResourceError, + RiseResourcePathError, + buildResourcePath, + clusterViewScopeOrThrow, + resolveClusterViewScope, + scopedApi, +} from './cluster-resource-api' +export type { + ClusterViewScope, + ResourceRequestOpts, + ResourceRequestScope, + ResourceResponse, +} from './cluster-resource-api' + +// 「从镜像仓库进入实例创建」的 URL 契约(跨插件只走 URL) +export { + CREATE_INSTANCE_ROUTES, + CREATE_INSTANCE_PARAM, + CREATE_INSTANCE_IMAGE_REF_PARAM, + CREATE_INSTANCE_IMAGE_TAG_PARAM, + CREATE_INSTANCE_IMAGE_SOURCE_PARAM, + CREATE_INSTANCE_NAMESPACE_PARAM, + CREATE_INSTANCE_CLUSTER_PARAM, + splitImageRef, + isValidCreateInstancePreset, + buildCreateInstanceUrl, + parseCreateInstancePreset, + clearCreateInstanceParams, +} from './create-instance' +export type { + CreateInstanceTarget, + CreateInstanceImageSource, + CreateInstancePreset, +} from './create-instance' + + +export type { RiseHostSDK as PluginHostSDK } from "./types"; diff --git a/web/packages/plugin-sdk/src/locales/sdk.en-US.ts b/web/packages/plugin-sdk/src/locales/sdk.en-US.ts new file mode 100644 index 0000000..e56b5d7 --- /dev/null +++ b/web/packages/plugin-sdk/src/locales/sdk.en-US.ts @@ -0,0 +1,17 @@ +// English wording for the SDK itself (business component wording lives in rise-global @theriseunion/business). +// +// Casing follows freeland 流程/国际化与英文文案开发规范 §2.3: navigation items and page +// titles use Title Case; buttons, column headers, field labels, hints and body text use +// sentence case. Scope pickers are field labels and filter dimensions, so sentence case. +// +// Apostrophes must be written as ’ (U+2019) or doubled ('') — ASCII ' is the ICU escape +// character and would swallow the rest of the segment. + +export default { + resourceApi: { + error: { + network: 'Network request failed. Check your connection.', + unauthorized: 'Not authorised. Sign in again.', + }, + }, +} as const diff --git a/web/packages/plugin-sdk/src/locales/sdk.zh-CN.ts b/web/packages/plugin-sdk/src/locales/sdk.zh-CN.ts new file mode 100644 index 0000000..bca87e1 --- /dev/null +++ b/web/packages/plugin-sdk/src/locales/sdk.zh-CN.ts @@ -0,0 +1,18 @@ +// SDK 自身文案的中文词表(业务组件文案在 rise-global @theriseunion/business)。也是未注入宿主时(单测 / SSR)的回落来源,所以这份必须是全集。 +// +// 结构按 rise-global `docs/superpowers/specs/2026-09-26-design-system-i18n-design.md` §4.1: +// key 为 `<组件名 camelCase>.<用途>`,多个组件共用的词放 `shared.*`;顶层不得用宿主 `common` +// 已有的 `actions` / `status` / `table` / `terms` / `misc`。 +// +// 插值与复数用 ICU 语法(`{name}`、`{n, plural, …}`),与宿主一致。 +// **中文值不得含 plural / select** —— 复数与选择只出现在英文值里。 +// 不要放代码样例或含 JSX 花括号的文本:ICU 会把单花括号当参数,整条渲染成 key 原文。 + +export default { + resourceApi: { + error: { + network: '网络连接失败,请检查网络设置。', + unauthorized: '未授权,请重新登录。', + }, + }, +} as const diff --git a/web/packages/plugin-sdk/src/react-jsx-runtime-shim.ts b/web/packages/plugin-sdk/src/react-jsx-runtime-shim.ts new file mode 100644 index 0000000..0c8cb39 --- /dev/null +++ b/web/packages/plugin-sdk/src/react-jsx-runtime-shim.ts @@ -0,0 +1,52 @@ +import { readHostSDK } from "./host-global"; +/* +`react/jsx-runtime` 的 shim —— 与 react-shim 同一个目的:让插件 bundle 里所有 +对 React 的引用都落到**宿主那一个 React 实例**上。 + +── 为什么单独一个文件,不能靠 react-shim 兜住 ──────────────────────────── + +构建预设把 `react` alias 到 react-shim。esbuild 的 alias 是**前缀替换**,于是 +`react/jsx-runtime` 会被拼成 `/jsx-runtime` —— 一个不存在的 +路径,报错长这样: + + ✘ Cannot read directory ".../react-shim.mjs": not a directory + ✘ Could not resolve ".../react-shim.mjs/jsx-runtime" (originally "react/jsx-runtime") + +而**现在发布的库大多编译到 automatic runtime**(`import {jsx} from +'react/jsx-runtime'`,不再是 `React.createElement`),react-markdown 就是。 +所以少了这一个 alias,这类库一个都进不了插件 bundle,而错误信息指向的是 +依赖库里的一行 import,插件作者根本不知道从哪查起 —— 与 react-shim 文件头 +记的那个「报在依赖库里」是同一类坑。 + +── 为什么用 createElement 实现,而不是转发宿主的 jsx-runtime ───────────── + +宿主通过 `window.__RISE_SDK__.React` 暴露的是 React 主入口,**没有** +jsx-runtime 那个子模块。而 automatic runtime 的语义可以用 createElement 完整 +表达(下面三行就是全部),所以自己实现比要求宿主再多暴露一个入口简单得多, +也不用两边同步版本。 +*/ +const host = readHostSDK() +const R = host?.React as any +if (!R) throw new Error('[modelsphere-plugin-sdk] React not found in __RISE_SDK__ — is this running inside Rise Console?') + +export const Fragment = R.Fragment + +/** + * automatic runtime 的 `jsx(type, config, maybeKey)`。 + * + * 与 createElement 的差别只有两点:children 在 config 里(不是尾随参数), + * key 单独作为第三个参数传。`createElement(type, props)` 本来就会把 config 里的 + * children 原样收进 props,把 key 从 props 里摘出来 —— 所以直接转发即可。 + */ +function jsxImpl(type: any, config: any, maybeKey?: any) { + if (maybeKey === undefined) return R.createElement(type, config) + return R.createElement(type, { ...config, key: maybeKey }) +} + +const runtime = host?.ReactJSXRuntime as { jsx?: typeof jsxImpl; jsxs?: typeof jsxImpl } | undefined +export const jsx = runtime?.jsx ?? jsxImpl +// jsxs 只是「children 已知是数组」的版本,语义与 jsx 相同。 +export const jsxs = runtime?.jsxs ?? jsxImpl +// 开发态入口。构建预设把 NODE_ENV 定成 production,正常走不到这里; +// 导出它是为了别在某个库显式 import 时炸成 "No matching export"。 +export const jsxDEV = (host?.ReactJSXDevRuntime as { jsxDEV?: typeof jsxImpl } | undefined)?.jsxDEV ?? jsxImpl diff --git a/web/packages/plugin-sdk/src/react-shim.ts b/web/packages/plugin-sdk/src/react-shim.ts new file mode 100644 index 0000000..e5fed35 --- /dev/null +++ b/web/packages/plugin-sdk/src/react-shim.ts @@ -0,0 +1,49 @@ +import { readHostSDK } from "./host-global"; +// @modelsphere/plugin-sdk — React shim for UMD plugins +// 插件 bundle 里的所有 `import React / react-dom` 都被构建预设 alias 到这里, +// 实际取宿主的 React 实例(单宿主唯一 React,见 ADR 插件模块格式选型)。 +// +// 导出面是 React 公开 API 的**完整常用面**,不是"目前被引用到的几个": +// 少导一个就是构建期 "No matching export" 硬报错 —— 但报在**依赖库**里(recharts 要 +// Component / PureComponent,lucide 要 forwardRef),插件作者看到的是一个自己没写过的 +// import 挂了。各插件此前各自维护一份 shim 补这个洞(observe 的最全),把 47 份配置 +// 收敛到 definePluginConfig 之后(freeland#179),这一份必须是它们的超集。 +const R = readHostSDK()?.React as any +if (!R) throw new Error('[modelsphere-plugin-sdk] React not found in __RISE_SDK__ — is this running inside Rise Console?') + +export default R +export const { + // hooks + useState, + useEffect, + useCallback, + useRef, + useMemo, + useContext, + useReducer, + useLayoutEffect, + useImperativeHandle, + useDebugValue, + useId, + useTransition, + useDeferredValue, + useSyncExternalStore, + useInsertionEffect, + // 元素与组件 + createElement, + cloneElement, + createContext, + createRef, + forwardRef, + memo, + lazy, + Suspense, + Fragment, + StrictMode, + Children, + Component, + PureComponent, + isValidElement, + startTransition, + version, +} = R diff --git a/web/packages/plugin-sdk/src/scope-filter.test.ts b/web/packages/plugin-sdk/src/scope-filter.test.ts new file mode 100644 index 0000000..d9bfd29 --- /dev/null +++ b/web/packages/plugin-sdk/src/scope-filter.test.ts @@ -0,0 +1,400 @@ +import React from 'react' +import TestRenderer, { act } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' + +import { useScopeFilter } from './scope-filter' +import type { UseScopeFilterOptions, UseScopeFilterResult } from './types' + +afterEach(() => { + vi.unstubAllGlobals() + delete (globalThis as Record).__RISE_SDK__ +}) + +function installHost(store: Map) { + ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + ;(globalThis as Record).__RISE_SDK__ = { + React, + get: (name: string) => (name === 'auth' ? { getUser: () => ({ username: 'u1' }) } : undefined), + } + vi.stubGlobal('window', { localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { store.set(k, v) }, + removeItem: (k: string) => { store.delete(k) }, + } }) +} + +async function render(opts?: UseScopeFilterOptions, kind: 'workspace' | 'cluster' = 'workspace') { + let result!: UseScopeFilterResult + function Probe() { + result = useScopeFilter(kind, opts) + return null + } + let renderer!: TestRenderer.ReactTestRenderer + await act(async () => { renderer = TestRenderer.create(React.createElement(Probe)) }) + return { get: () => result, renderer } +} + +const workspaces = (names: string[]) => ({ + ok: true, + json: async () => ({ items: names.map(name => ({ metadata: { name } })) }), +}) + +it('默认取全量列表(limit=500)但忽略旧工作空间记忆', async () => { + const store = new Map() + installHost(store) + const fetch = vi.fn().mockResolvedValue(workspaces(['ws-a', 'ws-b'])) + vi.stubGlobal('fetch', fetch) + store.set('rise-filter-workspace-u1', 'ws-b') + const { get, renderer } = await render() + expect(fetch.mock.calls[0][0]).toBe('/oapis/tenant.theriseunion.io/v1alpha1/workspaces?limit=500') + expect(get().value).toBe('') + expect(get().options.map(o => o.name)).toEqual(['ws-a', 'ws-b']) + await act(async () => { get().setValue('ws-a') }) + expect(get().value).toBe('ws-a') + expect(store.get('rise-filter-workspace-u1')).toBe('ws-b') + await act(async () => { renderer.unmount() }) +}) + +it('enabled=false:不取列表、不读写记忆,值恒为默认', async () => { + const store = new Map([['rise-filter-workspace-u1', 'ws-b']]) + installHost(store) + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + const { get, renderer } = await render({ enabled: false }) + expect(fetch).not.toHaveBeenCalled() + expect(get().value).toBe('') + expect(get().loading).toBe(false) + await act(async () => { get().setValue('ws-a') }) + expect(get().value).toBe('') + expect(store.get('rise-filter-workspace-u1')).toBe('ws-b') + await act(async () => { renderer.unmount() }) +}) + + +it.each([ + { kind: 'cluster' as const, expected: 'Canonical alias' }, + { kind: 'workspace' as const, expected: 'Compatibility name' }, +])('$kind keeps its display-name contract when both annotations exist', async ({ kind, expected }) => { + installHost(new Map()) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [{ + metadata: { name: 'host', annotations: { 'theriseunion.io/alias-name': 'Canonical alias', 'theriseunion.io/display-name': 'Compatibility name' } }, + }] }) })) + const { get, renderer } = await render(undefined, kind) + expect(get().options[0].displayName).toBe(expected) + await act(async () => { renderer.unmount() }) +}) + +function installUrlHost(initial: string) { + const listeners = new Map void>>() + const loc = { pathname: '/observe', search: '', hash: '' } + const apply = (url: string) => { + const u = new URL(url, 'http://h') + loc.pathname = u.pathname + loc.search = u.search + loc.hash = u.hash + } + apply(initial) + const replaceState = vi.fn((_s: unknown, _t: string, url: string) => apply(url)) + ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + ;(globalThis as Record).__RISE_SDK__ = { React, get: () => undefined } + vi.stubGlobal('window', { + location: loc, + history: { state: null, replaceState }, + addEventListener: (n: string, f: () => void) => { listeners.set(n, [...(listeners.get(n) || []), f]) }, + removeEventListener: (n: string, f: () => void) => { listeners.set(n, (listeners.get(n) || []).filter(x => x !== f)) }, + }) + return { + loc, + replaceState, + navigate: (url: string) => { apply(url); (listeners.get('popstate') || []).forEach(f => f()) }, + url: () => loc.pathname + loc.search + loc.hash, + } +} + +const clusters = (names: string[]) => workspaces(names) + +it('syncUrl 缺省:不读写 URL', async () => { + const host = installUrlHost('/observe?cluster=east') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east']))) + const { get, renderer } = await render({ defaultValue: '' }, 'cluster') + expect(get().value).toBe('') + await act(async () => { get().setValue('east') }) + expect(host.replaceState).not.toHaveBeenCalled() + expect(host.url()).toBe('/observe?cluster=east') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:挂载读取参数,优先于 defaultValue;无参数用 defaultValue', async () => { + installUrlHost('/observe?cluster=east') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east', 'west']))) + const a = await render({ defaultValue: '', syncUrl: true }, 'cluster') + expect(a.get().value).toBe('east') + await act(async () => { a.renderer.unmount() }) + + installUrlHost('/observe') + const b = await render({ defaultValue: '', syncUrl: true }, 'cluster') + expect(b.get().value).toBe('') + await act(async () => { b.renderer.unmount() }) +}) + +it('syncUrl:setValue 写/删参数,保留 pathname、hash 与其它参数', async () => { + const host = installUrlHost('/observe?range=1h#top') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east']))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + await act(async () => { get().setValue('east') }) + expect(host.url()).toBe('/observe?range=1h&cluster=east#top') + expect(get().value).toBe('east') + await act(async () => { get().setValue('') }) + expect(host.url()).toBe('/observe?range=1h#top') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:自定义参数名;defaultValue 非空时空串写成空参数', async () => { + const host = installUrlHost('/observe') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['host', 'east']))) + const { get, renderer } = await render({ syncUrl: { param: 'c' } }, 'cluster') + expect(get().value).toBe('host') + await act(async () => { get().setValue('') }) + expect(host.url()).toBe('/observe?c=') + await act(async () => { get().setValue('host') }) + expect(host.url()).toBe('/observe') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:列表加载后无效参数回落默认并从 URL 移除', async () => { + const host = installUrlHost('/observe?cluster=gone&range=1h') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east']))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + expect(get().value).toBe('') + expect(host.url()).toBe('/observe?range=1h') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:popstate 重读参数', async () => { + const host = installUrlHost('/observe') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east']))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + await act(async () => { host.navigate('/observe?cluster=east') }) + expect(get().value).toBe('east') + await act(async () => { host.navigate('/observe') }) + expect(get().value).toBe('') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:popstate 到候选里没有的值时回落默认并从 URL 移除', async () => { + const host = installUrlHost('/observe?range=1h') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east']))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + await act(async () => { host.navigate('/observe?cluster=gone&range=1h') }) + expect(get().value).toBe('') + expect(host.url()).toBe('/observe?range=1h') + await act(async () => { host.navigate('/observe?cluster=east&range=1h') }) + expect(get().value).toBe('east') + expect(host.url()).toBe('/observe?cluster=east&range=1h') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:取列表失败不改写 URL,值保持 URL 值;popstate 仍按 URL 值', async () => { + const host = installUrlHost('/observe?cluster=east&range=1h') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + expect(get().value).toBe('east') + expect(get().options).toEqual([]) + expect(host.replaceState).not.toHaveBeenCalled() + expect(host.url()).toBe('/observe?cluster=east&range=1h') + await act(async () => { host.navigate('/observe?cluster=west&range=1h') }) + expect(get().value).toBe('west') + expect(host.replaceState).not.toHaveBeenCalled() + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl 关闭:取列表失败回落默认值', async () => { + installUrlHost('/observe') + let fail: (e: Error) => void = () => {} + vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise((_, reject) => { fail = reject }))) + const { get, renderer } = await render({ defaultValue: 'host' }, 'cluster') + await act(async () => { get().setValue('east') }) + expect(get().value).toBe('east') + await act(async () => { fail(new Error('network')) }) + expect(get().value).toBe('host') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:replaceState 的 state 去掉 Next 内部标记并保留其它键', async () => { + const host = installUrlHost('/observe?cluster=east') + ;(window.history as { state: unknown }).state = { + __NA: true, __PRIVATE_NEXTJS_INTERNALS_TREE: ['x'], _N: 1, 'rise-navigation-owner': 'observe', other: 1, + } + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east', 'west']))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + await act(async () => { get().setValue('west') }) + expect(host.replaceState).toHaveBeenCalledTimes(1) + expect(host.replaceState.mock.calls[0][0]).toEqual({ 'rise-navigation-owner': 'observe', other: 1 }) + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:state 只有 Next 标记时传 null', async () => { + const host = installUrlHost('/observe?cluster=east') + ;(window.history as { state: unknown }).state = { __NA: true, __PRIVATE_NEXTJS_INTERNALS_TREE: [] } + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east', 'west']))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + await act(async () => { get().setValue('west') }) + expect(host.replaceState.mock.calls[0][0]).toBeNull() + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl:已离开挂载页面后的 popstate 不读不改', async () => { + const host = installUrlHost('/observe?cluster=east') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east']))) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true }, 'cluster') + await act(async () => { host.navigate('/other?cluster=bogus') }) + expect(get().value).toBe('east') + expect(host.replaceState).not.toHaveBeenCalled() + expect(host.url()).toBe('/other?cluster=bogus') + await act(async () => { renderer.unmount() }) +}) + +it('syncUrl + enabled=false:不读不写 URL', async () => { + const host = installUrlHost('/observe?cluster=east') + vi.stubGlobal('fetch', vi.fn()) + const { get, renderer } = await render({ defaultValue: '', syncUrl: true, enabled: false }, 'cluster') + expect(get().value).toBe('') + await act(async () => { get().setValue('east') }) + await act(async () => { host.navigate('/observe?cluster=east') }) + expect(get().value).toBe('') + expect(host.replaceState).not.toHaveBeenCalled() + await act(async () => { renderer.unmount() }) +}) + +it('error:成功路径为 null,retry 可用', async () => { + installUrlHost('/observe') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(clusters(['east']))) + const { get, renderer } = await render({ defaultValue: '' }, 'cluster') + expect(get().error).toBeNull() + expect(typeof get().retry).toBe('function') + await act(async () => { renderer.unmount() }) +}) + +it('error:取列表失败置 error 且 options 为空;retry 重新请求并在成功后清除 error', async () => { + installUrlHost('/observe') + const fetch = vi.fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce(clusters(['east', 'west'])) + vi.stubGlobal('fetch', fetch) + const { get, renderer } = await render({ defaultValue: '' }, 'cluster') + expect(get().error).toBeInstanceOf(Error) + expect(get().options).toEqual([]) + expect(get().loading).toBe(false) + await act(async () => { get().retry?.() }) + expect(fetch).toHaveBeenCalledTimes(2) + expect(get().error).toBeNull() + expect(get().options.map(o => o.name)).toEqual(['east', 'west']) + await act(async () => { renderer.unmount() }) +}) + +it('error:HTTP 非 2xx 也算失败;retry 再失败仍保持 error', async () => { + installUrlHost('/observe') + const fetch = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}) }) + vi.stubGlobal('fetch', fetch) + const { get, renderer } = await render({ defaultValue: '' }, 'cluster') + expect(get().error).toBeInstanceOf(Error) + await act(async () => { get().retry?.() }) + expect(fetch).toHaveBeenCalledTimes(2) + expect(get().error).toBeInstanceOf(Error) + await act(async () => { renderer.unmount() }) +}) + +it('error:retry 期间 error 清空并处于 loading', async () => { + installUrlHost('/observe') + let resolve: (v: unknown) => void = () => {} + vi.stubGlobal('fetch', vi.fn() + .mockRejectedValueOnce(new Error('network')) + .mockReturnValueOnce(new Promise((r) => { resolve = r }))) + const { get, renderer } = await render({ defaultValue: '' }, 'cluster') + expect(get().error).toBeInstanceOf(Error) + await act(async () => { get().retry?.() }) + expect(get().error).toBeNull() + expect(get().loading).toBe(true) + await act(async () => { resolve(clusters(['east'])) }) + expect(get().loading).toBe(false) + expect(get().options.map(o => o.name)).toEqual(['east']) + await act(async () => { renderer.unmount() }) +}) + +it('error:失败时 syncUrl 关闭仍回落默认值、syncUrl 开启仍保持 URL 值(既有语义不变)', async () => { + installUrlHost('/observe?cluster=east') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))) + const a = await render({ defaultValue: '', syncUrl: true }, 'cluster') + expect(a.get().value).toBe('east') + expect(a.get().error).toBeInstanceOf(Error) + await act(async () => { a.renderer.unmount() }) + const b = await render({ defaultValue: 'host' }, 'cluster') + expect(b.get().value).toBe('host') + expect(b.get().error).toBeInstanceOf(Error) + await act(async () => { b.renderer.unmount() }) +}) + +it('error:enabled=false 不取列表,error 恒为 null', async () => { + installUrlHost('/observe') + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + const { get, renderer } = await render({ enabled: false }, 'cluster') + expect(get().error).toBeNull() + await act(async () => { get().retry?.() }) + expect(fetch).not.toHaveBeenCalled() + await act(async () => { renderer.unmount() }) +}) + + +it('pluginName:仅请求指定插件的可用集群,不读 Tenant 或安装记录', async () => { + const host = installUrlHost('/observe?cluster=uninstalled') + const fetch = vi.fn().mockResolvedValue(clusters(['installed'])) + vi.stubGlobal('fetch', fetch) + const { get, renderer } = await render({ pluginName: 'rise.io--observe', defaultValue: '', syncUrl: true }, 'cluster') + expect(fetch.mock.calls.map(call => call[0])).toEqual([ + '/oapis/tenant.theriseunion.io/v1alpha1/plugins/rise.io--observe/clusters?limit=-1', + ]) + expect(get().options.map(option => option.name)).toEqual(['installed']) + expect(get().value).toBe('') + expect(host.url()).toBe('/observe') + await act(async () => { host.navigate('/observe?cluster=uninstalled') }) + expect(get().value).toBe('') + await act(async () => { host.navigate('/observe?cluster=installed') }) + expect(get().value).toBe('installed') + await act(async () => { renderer.unmount() }) +}) + +it('pluginName:空交集清除直链集群,不自动选 host,也不回退', async () => { + const host = installUrlHost('/observe?cluster=host&range=1h') + const fetch = vi.fn().mockResolvedValue(clusters([])) + vi.stubGlobal('fetch', fetch) + const { get, renderer } = await render({ pluginName: 'rise.io--observe', defaultValue: '', syncUrl: true }, 'cluster') + expect(get().options).toEqual([]) + expect(get().value).toBe('') + expect(get().error).toBeNull() + expect(fetch).toHaveBeenCalledTimes(1) + expect(host.url()).toBe('/observe?range=1h') + await act(async () => { renderer.unmount() }) +}) + +it.each([404, 409, 500])('pluginName:HTTP %s 保留真实错误且不 fallback,重试仍走新接口', async status => { + const host = installUrlHost('/observe?cluster=host') + const fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ message: `plugin failure ${status}` }), { status })) + .mockResolvedValueOnce(clusters(['installed'])) + vi.stubGlobal('fetch', fetch) + const { get, renderer } = await render({ pluginName: 'rise.io--observe', defaultValue: '', syncUrl: true }, 'cluster') + expect(String(get().error)).toContain(`HTTP ${status}`) + expect(String(get().error)).toContain(`plugin failure ${status}`) + expect(get().options).toEqual([]) + expect(host.url()).toBe('/observe?cluster=host') + expect(fetch).toHaveBeenCalledTimes(1) + await act(async () => { get().retry?.() }) + expect(fetch.mock.calls.map(call => call[0])).toEqual(Array(2).fill( + '/oapis/tenant.theriseunion.io/v1alpha1/plugins/rise.io--observe/clusters?limit=-1', + )) + expect(get().error).toBeNull() + expect(get().options.map(option => option.name)).toEqual(['installed']) + expect(get().value).toBe('') + await act(async () => { renderer.unmount() }) +}) diff --git a/web/packages/plugin-sdk/src/scope-filter.ts b/web/packages/plugin-sdk/src/scope-filter.ts new file mode 100644 index 0000000..35d6406 --- /dev/null +++ b/web/packages/plugin-sdk/src/scope-filter.ts @@ -0,0 +1,222 @@ +import { getHostSDK } from './host' +import type { + ScopeFilterKind, + ScopeFilterOption, + UseScopeFilterOptions, + UseScopeFilterResult, +} from './types' + +type ReactLike = { + useCallback: any>(callback: T, deps: unknown[]) => T + useEffect: (effect: () => void | (() => void), deps?: unknown[]) => void + useMemo: (factory: () => T, deps: unknown[]) => T + useRef: (initial: T) => { current: T } + useState: (initial: T | (() => T)) => [T, (next: T | ((prev: T) => T)) => void] +} + +type K8sListItem = { + metadata?: { + name?: string + annotations?: Record + } + spec?: Record +} + +type K8sList = { + items?: K8sListItem[] +} + +const DISPLAY_NAME_ANNOTATIONS = [ + 'theriseunion.io/display-name', + 'theriseunion.io/alias-name', + 'rise.io/display-name', + 'displayName', + 'display-name', +] + +function getReact(): ReactLike { + return getHostSDK().React as ReactLike +} + +function getDefaultValue(kind: ScopeFilterKind, opts?: UseScopeFilterOptions): string { + return opts?.defaultValue ?? (kind === 'cluster' ? 'host' : '') +} + +function getUrlParam(kind: ScopeFilterKind, opts?: UseScopeFilterOptions): string | null { + const sync = opts?.syncUrl + if (!sync) return null + return (typeof sync === 'object' && sync.param) || kind +} + +function hasLocation(): boolean { + return typeof window !== 'undefined' && !!window.location && !!window.history +} + +/** 参数缺省返回 null;`?p=` 返回空串。 */ +function readUrlParam(param: string): string | null { + if (!hasLocation()) return null + return new URLSearchParams(window.location.search).get(param) +} + +/** Next app-router 写入 history.state 的内部键;带着它们调用 replaceState,Next 的补丁会放行而不更新自己记录的 URL。 */ +const NEXT_STATE_KEYS = ['__NA', '__PRIVATE_NEXTJS_INTERNALS_TREE', '_N'] + +/** 去掉 Next 内部键、保留其它键(含宿主壳的 navigation owner);无剩余键时为 null。 */ +function stateWithoutNextMarkers(state: unknown): Record | null { + if (!state || typeof state !== 'object') return null + const rest = { ...(state as Record) } + for (const key of NEXT_STATE_KEYS) delete rest[key] + return Object.keys(rest).length ? rest : null +} + +/** + * value 为 null 时移除参数;只改 query,保留 pathname、hash 与其它参数,用 replaceState 不增加历史条目。 + * state 先去掉 Next 内部标记,Next 补丁过的 replaceState 才会采纳新 URL(useSearchParams 随之更新)。 + */ +function writeUrlParam(param: string, value: string | null) { + if (!hasLocation()) return + const params = new URLSearchParams(window.location.search) + if (value === null) params.delete(param) + else params.set(param, value) + const search = params.toString() + const next = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}` + if (next === `${window.location.pathname}${window.location.search}${window.location.hash}`) return + window.history.replaceState(stateWithoutNextMarkers(window.history.state), '', next) +} + +function displayNameOf(item: K8sListItem, cluster = false): string | undefined { + const annotations = item.metadata?.annotations || {} + if (cluster) { + const alias = annotations['theriseunion.io/alias-name'] + if (typeof alias === 'string' && alias.trim()) return alias + } + for (const key of DISPLAY_NAME_ANNOTATIONS) { + const value = annotations[key] + if (typeof value === 'string' && value.trim()) return value + } + + const spec = item.spec || {} + for (const key of ['displayName', 'display_name', 'aliasName', 'alias']) { + const value = spec[key] + if (typeof value === 'string' && value.trim()) return value + } + + return undefined +} + +function mapOptions(list: K8sList, cluster = false): ScopeFilterOption[] { + return (list.items || []) + .map((item) => { + const name = item.metadata?.name || '' + if (!name) return null + const displayName = displayNameOf(item, cluster) + return displayName ? { name, displayName } : { name } + }) + .filter((item): item is ScopeFilterOption => item !== null) +} + +function useScopeSelection(kind: ScopeFilterKind, opts?: UseScopeFilterOptions) { + const React = getReact() + const defaultValue = getDefaultValue(kind, opts) + const enabled = opts?.enabled !== false + const urlParam = enabled ? getUrlParam(kind, opts) : null + const initialValue = () => (urlParam ? readUrlParam(urlParam) ?? defaultValue : defaultValue) + const [value, setValueState] = React.useState(initialValue) + React.useEffect(() => { setValueState(initialValue()) }, [kind, opts?.defaultValue, enabled, urlParam]) + return { defaultValue, enabled, urlParam, value, setValueState } +} + +type Selection = ReturnType + +function useScopeHistory(selection: Selection, options: ScopeFilterOption[]) { + const React = getReact() + const { urlParam, defaultValue, setValueState } = selection + const optionsRef = React.useRef(options) + optionsRef.current = options + React.useEffect(() => { + if (!urlParam || typeof window === 'undefined' || !window.addEventListener) return + const mountedPath = hasLocation() ? window.location.pathname : null + const onPopState = () => { + if (hasLocation() && window.location.pathname !== mountedPath) return + const next = readUrlParam(urlParam) ?? defaultValue + const loaded = optionsRef.current + if (next !== '' && loaded.length > 0 && !loaded.some(option => option.name === next)) { + setValueState(defaultValue) + writeUrlParam(urlParam, null) + return + } + setValueState(next) + } + window.addEventListener('popstate', onPopState) + return () => window.removeEventListener('popstate', onPopState) + }, [urlParam, defaultValue]) +} + +async function fetchScopeOptions(kind: ScopeFilterKind, pluginName?: string): Promise { + const resource = kind === 'cluster' ? 'clusters' : 'workspaces' + // Fetch all candidates so a remembered selection beyond the first page stays valid. + const url = pluginName + ? `/oapis/tenant.theriseunion.io/v1alpha1/plugins/${encodeURIComponent(pluginName)}/clusters?limit=-1` + : `/oapis/tenant.theriseunion.io/v1alpha1/${resource}?limit=500` + const response = await fetch(url, { credentials: 'include' }) + if (!response.ok) { + const reason = pluginName ? await response.text() : '' + throw new Error(`HTTP ${response.status}${reason ? `: ${reason}` : ''}`) + } + return response.json() as Promise +} + +function useScopeOptions(kind: ScopeFilterKind, selection: Selection, pluginName?: string) { + const React = getReact() + const { defaultValue, enabled, urlParam, setValueState, value } = selection + const valueRef = React.useRef(value) + valueRef.current = value + const [options, setOptions] = React.useState([]) + const [loading, setLoading] = React.useState(enabled) + const [error, setError] = React.useState(null) + const [reloadToken, setReloadToken] = React.useState(0) + React.useEffect(() => { + if (!enabled) { + setOptions([]) + setError(null) + setLoading(false) + return + } + let cancelled = false + setLoading(true) + setError(null) + fetchScopeOptions(kind, pluginName).then(list => { + if (cancelled) return + const nextOptions = mapOptions(list, kind === 'cluster') + setOptions(nextOptions) + setError(null) + const current = valueRef.current + if (current === '' || nextOptions.some(option => option.name === current)) return + setValueState(defaultValue) + if (urlParam) writeUrlParam(urlParam, null) + }).catch((failure: unknown) => { + if (cancelled) return + setOptions([]) + setError(failure ?? new Error('request failed')) + // A failed URL-synced request preserves the selection for a later retry. + if (!urlParam) setValueState(defaultValue) + }).finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [defaultValue, kind, enabled, urlParam, reloadToken, pluginName]) + const retry = React.useCallback(() => { setReloadToken(token => token + 1) }, []) + return { options, loading, error, retry } +} + +export function useScopeFilter(kind: ScopeFilterKind, opts?: UseScopeFilterOptions): UseScopeFilterResult { + const React = getReact() + const selection = useScopeSelection(kind, opts) + const { enabled, defaultValue, urlParam, value, setValueState } = selection + const result = useScopeOptions(kind, selection, kind === 'cluster' ? opts?.pluginName : undefined) + useScopeHistory(selection, result.options) + const setValue = React.useCallback((next: string) => { + if (!enabled) return + setValueState(next) + if (urlParam) writeUrlParam(urlParam, next === defaultValue ? null : next) + }, [enabled, urlParam, defaultValue]) + return { value, setValue, ...result } +} diff --git a/web/packages/plugin-sdk/src/shared-imports.ts b/web/packages/plugin-sdk/src/shared-imports.ts new file mode 100644 index 0000000..1e52d59 --- /dev/null +++ b/web/packages/plugin-sdk/src/shared-imports.ts @@ -0,0 +1,37 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +export const HOST_SURFACES = { "react-router": "business.console.Router", "@tanstack/react-query": "business.console.Query", "@modelsphere/ui": "ui" }; + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(file) : /\.tsx?$/.test(file) && !/\.test\./.test(file) ? [file] : []; + }); +} + +// Generate the shared import surface from source imports, keeping host contexts singletons. +export function sharedNames(source: string, typescript: typeof import("typescript")) { + const names: Record> = Object.fromEntries(Object.keys(HOST_SURFACES).map((name) => [name, new Set()])); + for (const file of sourceFiles(source)) { + const ast = typescript.createSourceFile(file, readFileSync(file, "utf8"), typescript.ScriptTarget.Latest, true); + for (const statement of ast.statements) { + if (!typescript.isImportDeclaration(statement) || !names[(statement.moduleSpecifier as import("typescript").StringLiteral).text]) continue; + const clause = statement.importClause; + if (!clause || clause.isTypeOnly) continue; + if (!clause.namedBindings || !typescript.isNamedImports(clause.namedBindings) || clause.name) throw new Error(`Shared modules require named imports: ${file}`); + for (const item of clause.namedBindings.elements) if (!item.isTypeOnly) names[(statement.moduleSpecifier as import("typescript").StringLiteral).text].add(item.propertyName?.text ?? item.name.text); + } + } + return names; +} + +export function sharedImports(source: string, typescript: typeof import("typescript")): NonNullable[number] { + const names = sharedNames(source, typescript); + return { name: "modelsphere-shared-host", setup(build) { + build.onResolve({ filter: /^(react-router|@tanstack\/react-query|@modelsphere\/ui)$/ }, ({ path: name }) => ({ path: name, namespace: "modelsphere-shared-host" })); + build.onLoad({ filter: /.*/, namespace: "modelsphere-shared-host" }, ({ path: name }) => ({ + contents: `const shared=(window.__MODELSPHERE_SDK__ || window.__GLOBAL_SDK__ || window.__RISE_SDK__).${HOST_SURFACES[name as keyof typeof HOST_SURFACES]};\n` + [...names[name]].sort().map((key) => `export const ${key}=shared.${key};`).join("\n"), loader: "js", + })); + } }; +} diff --git a/web/packages/plugin-sdk/src/testing.ts b/web/packages/plugin-sdk/src/testing.ts new file mode 100644 index 0000000..9a76398 --- /dev/null +++ b/web/packages/plugin-sdk/src/testing.ts @@ -0,0 +1,118 @@ +// @modelsphere/plugin-sdk/testing — Mock utilities for plugin development + +export interface MockSDKOptions { + tenant?: { id: string; name: string; displayName: string } + user?: { id: string; name: string; roles: string[] } + viewMode?: 'console' | 'boss' + locale?: string +} + +export interface MockSDK { + React: unknown + VERSION: string + registerPlugin: (def: unknown) => void + registerPluginComponent: (pluginName: string, componentName: string, component: unknown) => void + registerSlot: (def: unknown) => void + registerSection: (def: unknown) => void + createPlugin: (def: unknown) => unknown + registeredPlugins: unknown[] + registeredComponents: Array<{ pluginName: string; componentName: string; component: unknown }> + registeredSlots: unknown[] + registeredSections: unknown[] +} + +/** + * Create a mock SDK for testing plugins outside of Rise Console. + * + * Records all registration calls for assertion. + * + * @example + * ```ts + * import { createMockSDK } from '@modelsphere/plugin-sdk/testing' + * + * const mock = createMockSDK({ tenant: { id: 't1', name: 'Test', displayName: 'Test Org' } }) + * ;(window as any).__RISE_SDK__ = mock + * + * // Load your plugin... + * require('./index') + * + * // Assert registrations + * expect(mock.registeredPlugins).toHaveLength(1) + * expect(mock.registeredPlugins[0].name).toBe('hello-world') + * expect(mock.registeredComponents).toHaveLength(1) + * ``` + */ +function registerMockPlugin(mock: MockSDK, def: any) { + if (!def?.name) return def + // Register sections + if (def.sections) { + for (const section of def.sections) { + mock.registerSection({ + name: section.name, + displayName: section.displayName, + icon: section.icon || '', + order: section.order ?? 999, + scope: section.scope || '', + }) + } + } + // Register menus + mock.registerPlugin({ + name: def.name, + version: def.version || '0.0.0', + displayName: def.displayName || def.name, + menus: (def.menus || []).map((m: any) => ({ + ...m, + scope: m.scope || '', + })), + }) + // Register components + if (def.components) { + for (const [name, comp] of Object.entries(def.components)) { + mock.registerPluginComponent(def.name, name, comp) + } + } + // Register slots + if (def.slots) { + for (const slot of def.slots as any[]) { + mock.registerSlot({ ...slot, plugin: def.name }) + } + } + return { name: def.name, api: {}, events: {}, nav: {} } +} + +export function createMockSDK(opts?: MockSDKOptions): MockSDK { + const registeredPlugins: unknown[] = [] + const registeredComponents: MockSDK['registeredComponents'] = [] + const registeredSlots: unknown[] = [] + const registeredSections: unknown[] = [] + + const mock: MockSDK = { + VERSION: '0.1.0', + React: { createElement: () => null }, + registeredPlugins, + registeredComponents, + registeredSlots, + registeredSections, + + registerPlugin(def: unknown) { + registeredPlugins.push(def) + }, + + registerPluginComponent(pluginName: string, componentName: string, component: unknown) { + registeredComponents.push({ pluginName, componentName, component }) + }, + + registerSlot(def: unknown) { + registeredSlots.push(def) + }, + + registerSection(def: unknown) { + registeredSections.push(def) + }, + + createPlugin: (def: unknown) => registerMockPlugin(mock, def), + } + + return mock +} diff --git a/web/packages/plugin-sdk/src/types.ts b/web/packages/plugin-sdk/src/types.ts new file mode 100644 index 0000000..a4482e1 --- /dev/null +++ b/web/packages/plugin-sdk/src/types.ts @@ -0,0 +1,478 @@ +// ============================================================ +// @modelsphere/plugin-sdk — TypeScript Type Definitions +// Design doc: _bmad-output/design-plugin-sdk-api.md +// ============================================================ + +// ── Scope (Resource Scope) ────────────────────────────────── + +/** + * Resource scope type in Rise Global's hierarchical permission model. + * The host uses `project` for project URLs; `namespace` is retained as a + * legacy alias for compatibility with existing plugins. + */ +export const SCOPE_TYPES = [ + 'platform', + 'cluster', + 'workspace', + 'project', + 'namespace', + 'nodegroup', +] as const + +export type ScopeType = (typeof SCOPE_TYPES)[number] + +/** + * Scope represents the current page's resource boundary. + * + * Rise Global organises resources hierarchically: + * platform → cluster → nodegroup + * → workspace → project + * + * The host resolves the Scope from the current URL automatically. + * Plugins should use `scope` to decide API call boundaries, + * permission checks, and UI data granularity. + */ +export interface Scope { + /** Scope level */ + type: ScopeType + /** Concrete identifier (cluster ID, workspace ID, etc.). 'global' at platform level. */ + value: string + /** Full identifier: `{type}/{value}`, e.g. 'project/project-a' */ + full: string +} + +// ── View Mode ──────────────────────────────────────────────── + +/** + * View mode determines menu visibility. Matches backend menu scope values. + * 'boss' = platform admin (infrastructure management) + * 'console' = tenant user (business operations) + */ +export type ViewMode = 'boss' | 'console' + +/** 菜单的资源上下文(决定 URL 前缀与侧边栏选择器),与 viewport(ViewMode) 是两个独立维度。 + * 历史上此字段曾误标为 ViewMode —— 宿主实际一直按资源上下文处理(见 rise-global CLAUDE.md + * 「两个独立维度」),此类型是对既有行为的如实描述,非契约变更。 */ +export type MenuScope = 'platform' | 'cluster' | 'nodegroup' | 'workspace' | 'project' + +// ── Menu ──────────────────────────────────────────────────── + +/** A single menu item registered by a plugin. */ +export interface PluginMenu { + /** Unique identifier within the plugin */ + name: string + /** Display text——**中文真源,应始终提供**(ADR-0011)。中文态直接渲染此值; + * 英文态渲染时按 name 派生 {ns}:menu.{camelCase(name)} 查 locales/menu.en-US.json, + * 查不到回落本字段。注册表不为 i18n 变形,英文是渲染层增量。 */ + title?: string + /** Route path (relative to the parent module, e.g. '/dashboard') */ + path: string + /** Lucide icon name, kebab-case (e.g. 'bar-chart-2') */ + icon?: string + /** 资源上下文:决定 URL 前缀与侧边栏选择器(platform 无前缀 / cluster / workspace / project …) */ + scope: MenuScope + /** Parent plugin name for sidebar injection (omit = own sidebar, set = inject into parent's sidebar) */ + parent?: string + /** Sidebar group title——中文真源,同时是跨插件分组合并 key(多插件注入同一侧边栏须逐字一致)。 + * 英文态按值查 {ns}:group.{中文值},缺则回落原串(ADR-0011)。如 '算力服务' */ + group?: string + /** Sort order (lower = higher). Default 999. */ + order?: number + /** Permission key for RBAC filtering */ + authKey?: string + /** Hidden from sidebar (path→component mapping only) */ + hidden?: boolean + /** Limit menu visibility to specific resource scope levels */ + requiredScope?: ScopeType[] + /** Nested child menu items */ + children?: PluginMenu[] +} + +// ── Context ───────────────────────────────────────────────── + +/** Runtime context injected into plugin lifecycle hooks. */ +export interface PluginContext { + /** Current plugin metadata */ + plugin: { + name: string + version: string + /** Trust level derived from frontend.type + runtime */ + trust: 'trusted' | 'verified' | 'sandboxed' + } + + /** Current resource scope (auto-resolved from URL by host) */ + scope: Scope + + /** Current tenant */ + tenant: { + id: string + name: string + displayName: string + } + + /** Current user */ + user: { + id: string + name: string + roles: string[] + } + + /** Current view mode: boss (admin) or console (tenant) */ + activeScope: ViewMode + + /** Current locale(onMount 时的一次性快照;响应式请用 SDK.useLocale(),勿静态读此值) */ + locale: string + + /** Theme variables */ + theme: { + mode: 'light' | 'dark' + primaryColor: string + cssVariables: Record + } + + /** + * Check if the current user has a permission in the current scope. + * Format: '{scopeType}/{resource}.{action}', e.g. 'project/pod.view' + */ + hasPermission: (permission: string) => boolean +} + +// ── Sub-module interfaces (Phase 0: stubs) ────────────────── + +/** HTTP proxy — auto-injects auth token and tenant header. */ +export interface PluginAPI { + get(path: string, opts?: RequestOpts): Promise + post(path: string, opts?: RequestOpts): Promise + put(path: string, opts?: RequestOpts): Promise + delete(path: string, opts?: RequestOpts): Promise +} + +export interface RequestOpts { + body?: unknown + params?: Record + headers?: Record + /** Override the auto-resolved scope for this request */ + scopeOverride?: Pick + /** Skip scope injection entirely */ + noScope?: boolean +} + +/** Typed events shared by the host and plugins. */ +export interface PluginEventMap { + 'scope:change': { scope: ScopeType; value: string; full: string } + 'plugin:enabled': unknown + 'plugin:disabled': unknown + 'resource:change': unknown + 'nav:change': unknown +} + +/** Cross-plugin event bus (pub/sub). */ +export interface PluginEvents { + /** Publish an event. */ + emit(event: Event, data: PluginEventMap[Event]): void + /** Subscribe. Returns an unsubscribe function. */ + on( + event: Event, + handler: (data: PluginEventMap[Event]) => void, + ): () => void + /** Subscribe once. */ + once( + event: Event, + handler: (data: PluginEventMap[Event]) => void, + ): () => void +} + +/** Dynamic navigation operations. */ +export interface PluginNav { + addMenu(menu: PluginMenu): void + removeMenu(name: string): void + setBadge(menuName: string, badge: { count: number; type?: 'info' | 'warning' | 'error' }): void +} + +// ── Slot ──────────────────────────────────────────────────── + +/** Slot injection into host-defined extension points. */ +export interface SlotRegistration { + /** Slot type: where to inject */ + type: + | 'dashboard-widget' + | 'detail-tab' + | 'toolbar-action' + | 'page-header' + | 'test-widget' + | 'project-overview-widget' + /** Unique slot name within the plugin */ + name: string + /** Registered component name to render */ + target: string + /** Sort order */ + order?: number +} + +// ── Scope Declaration (Dock metadata) ──────────────────────── + +/** Declares a Dock entry for a specific scope. */ +export interface ScopeDeclaration { + /** Resource scope: 'platform' | 'cluster' | 'workspace' | 'project' */ + scope: string + /** UI 模式:'boss'(管理)| 'console'(租户)。决定顶栏样式与 Dock 过滤(与 scope 是两个独立维度)。 + * 宿主侧 ScopeDeclaration 一直有此字段(ModuleNavContext),SDK 类型此前漏了 —— 补齐既有契约,非新增语义。 */ + viewport?: 'boss' | 'console' + /** Dock entry title——**中文真源,应始终提供**(ADR-0011);英文态查 {ns}:scope.{scope},缺则回落此值。 */ + title?: string + /** Lucide icon name, kebab-case */ + icon?: string + /** Dock group category. Omit for the default group. + * 分类译文由 host 侧 catLabel 统一映射(dock 分组是 host 聚合,非单插件所有),插件无需声明分类 i18n key。 */ + category?: string + /** Dock 点击落地路径(覆盖默认的“第一个菜单”;无侧边栏的插件用它) */ + path?: string +} + +// ── Plugin Definition (createPlugin input) ────────────────── + +/** Input to `createPlugin()`. */ +export interface PluginDefinition { + /** Plugin identifier (must match plugin.yaml name) */ + name: string + /** Semantic version */ + version: string + /** Human-readable display name */ + displayName: string + /** Lucide icon name for dock entry */ + icon?: string + + /** + * Dock entry declarations per scope. + * Each entry = one Dock button. Menus with matching scope are shown in that sidebar. + * Omit for contributed-only plugins (all menus have parent). + */ + scopes?: ScopeDeclaration[] + + /** Menu items to register */ + menus?: PluginMenu[] + + /** + * React components to register. Keys are component names, + * values are React component functions/classes. + * Registered as `{pluginName}/{componentName}` in the host. + */ + components?: Record + + /** Slot injections */ + slots?: SlotRegistration[] + + /** + * Dynamic route patterns for sub-pages (e.g. detail views). + * Keys are Next.js-style patterns: 'containers/[name]/details' + * Values are React component functions. + */ + routes?: Record + + // ── Lifecycle hooks ── + + /** Called after plugin is registered. Receives runtime context. */ + onMount?: (ctx: PluginContext) => void + /** Called when the resource scope changes (user switches project/workspace). */ + onScopeChange?: (newScope: Scope, oldScope: Scope) => void + /** Called when the plugin is deactivated. Clean up resources here. */ + onUnmount?: () => void +} + +// ── Plugin Instance (createPlugin return) ─────────────────── + +/** Returned by `createPlugin()`. Provides runtime SDK capabilities. */ +export interface PluginInstance { + /** Plugin name */ + readonly name: string + /** Pre-configured API client (auth + 401 handling) */ + readonly api: ApiClient + /** Typed cross-plugin event bus */ + readonly events: PluginEvents + /** Dynamic navigation (Phase 1) */ + readonly nav: PluginNav +} + +// ── Host API Client ──────────────────────────────────────── + +/** Response wrapper returned by host apiClient methods. */ +export interface ApiResponse { + data: T + status: number +} + +/** Pre-configured HTTP client provided by the host. Handles auth (cookie) and 401 redirect. */ +export interface ApiClient { + get(url: string, options?: { params?: Record }): Promise> + post(url: string, data?: unknown): Promise> + patch(url: string, data?: unknown): Promise> + delete(url: string): Promise> +} + +// ── Scope Context ───────────────────────────────────────── + +/** Runtime scope context reflecting current URL state. Updated by host on navigation. */ +export interface ScopeContextValue { + scope: string + cluster: string + workspace: string + project: string +} + +// ── 宿主注入面 ─────────────────────────────────────────── + +/** + * 宿主注入面 `window.__RISE_SDK__.ui`:`@theriseunion/ui` 的全部运行时导出。 + * + * 本包会发布到 npm registry,`.d.ts` 不能引用消费方装不到的 `@theriseunion/ui`,所以这里只是 `Record` 占位。 + * 精确类型由插件侧提供(需同级 clone rise-global 并 extends 仓库根 `tsconfig.base.json`): + * + * ```ts + * // src/lib/ui.ts + * import type * as UI from '@theriseunion/ui' + * export const ui = getUi() + * ``` + */ +export type UiSurface = Record + +/** 宿主注入面 `window.__RISE_SDK__.business`:`@theriseunion/business` 的全部运行时导出。精确类型同 {@link UiSurface},用 `getBusiness()`。 */ +export type BusinessSurface = Record + +/** 宿主权限判定(`usePermissions()` 的返回值)。权限异步拉取,加载期 `hasPermission` 返回 false,到达后自动重渲染。 */ +export interface PermissionsAPI { + /** 当前 scope 下是否拥有该权限 key */ + hasPermission: (permission: string) => boolean + /** 权限是否仍在加载 */ + loading: boolean + [key: string]: unknown +} + +// ── Auth API (SDK namespace: 'auth') ──────────────────────── + +/** Current user information. */ +export interface AuthUser { + username: string + email?: string + role?: string +} + +/** Auth namespace — access via `sdk.get('auth')` or `getAuth()`. */ +export interface AuthAPI { + getUser(): AuthUser + hasPermission(key: string): boolean // Phase 1: always returns true, reserved for RBAC +} + +// ── Page Scope Filter Hook ───────────────────────────────── + +export type ScopeFilterKind = 'cluster' | 'workspace' + +export interface ScopeFilterOption { + name: string + displayName?: string +} + +export interface UseScopeFilterOptions { + /** Plugin.metadata.name. Cluster candidates come only from the Tenant plugin clusters API. */ + pluginName?: string + defaultValue?: string + /** false 时不取列表,value 恒为默认值。供同时服务 console 与 boss 的公共 hook 按形态关闭。 */ + enabled?: boolean + /** + * 把选中值同步到 URL query,默认关闭。`true` 时参数名取 kind(`cluster` / `workspace`), + * `{ param }` 指定参数名。 + * - 初值:URL 有该参数则优先于 defaultValue(`?p=` 即空串,对应「全部」);无参数用 defaultValue。 + * - setValue:用 replaceState 写参数,保留 pathname、hash 与其它参数;等于 defaultValue 时移除参数。 + * - 监听 popstate 重读;列表加载后值无效则回落 defaultValue 并移除参数。 + * - enabled=false 时不读写 URL;不使用 localStorage / sessionStorage。 + */ + syncUrl?: boolean | { param?: string } +} + +export interface UseScopeFilterResult { + /** Empty string means all workspaces; cluster pages default to "host". */ + value: string + setValue: (v: string) => void + options: ScopeFilterOption[] + loading: boolean + /** + * 最近一次取候选列表失败的原因;加载中、成功或 enabled=false 时为 null。失败时 options 为空, + * 调用方靠它区分「列表加载失败」与「列表加载成功但为空」。忽略该字段的调用方行为不变。 + */ + error?: unknown | null + /** 重新取候选列表:立即清掉 error 并进入 loading;enabled=false 时无效。 */ + retry?: () => void +} + +// ── Host SDK interface (window.__RISE_SDK__) ──────────────── + +/** + * The raw host SDK exposed on `window.__RISE_SDK__`. + * Plugin developers should NOT use this directly — use typed accessors instead. + * This type is exported for internal/testing use only. + */ +export interface RiseHostSDK { + React: unknown + ReactJSXRuntime?: unknown + ReactJSXDevRuntime?: unknown + VERSION?: string + registerPlugin: (def: unknown) => void + registerPluginComponent: (pluginName: string, componentName: string, component: unknown) => void + /** Lookup every component the given plugin has registered. Added in Story 7.4. */ + getPluginComponents?: (pluginName: string) => Record + registerPluginRoutes: (pluginName: string, routes: Record) => void + registerSlot: (def: unknown) => void + createPlugin: (def: unknown) => unknown + /** + * Optional host event bus (console side, T2). When present, the SDK's + * `events` bridge to it so plugin subscriptions connect to host broadcasts; + * the SDK falls back to its in-bundle bus otherwise. + */ + on?: (event: string, handler: (data: unknown) => void, pluginName?: string) => (() => void) | void + once?: (event: string, handler: (data: unknown) => void, pluginName?: string) => (() => void) | void + emit?: (event: string, data: unknown) => void + /** Host current scope payload `{ scope, value, full }`, used by onMount and first-subscription replay. */ + getScope?: () => PluginEventMap['scope:change'] | null + /** Pre-configured API client for HTTP requests. */ + apiClient: ApiClient + /** Client-side navigation with auto scope prefix. */ + navigate: (url: string) => void + /** 宿主注入面:`@theriseunion/ui` 的全部运行时导出。插件经 `getUi()` 取用。 */ + ui: UiSurface + /** 宿主注入面:`@theriseunion/business` 的全部运行时导出。插件经 `getBusiness()` 取用。 */ + business: BusinessSurface + /** 宿主能力:权限判定 hook(React hook,只能在组件内调用)。插件经本包导出的 `usePermissions()` 调用。 */ + usePermissions: () => PermissionsAPI + /** Current scope context. Updated by host on navigation. */ + ScopeContext: ScopeContextValue + /** Current scope level (e.g. 'project'). Updated by host when user switches project. */ + scope: string + /** Current scope value (e.g. project/namespace ID). Updated by host when user switches project. */ + scopeValue: string + /** Subscribe to scope changes. */ + onScopeChange: (fn: (scope: string, scopeValue: string) => void) => void + /** Unsubscribe from scope changes. */ + offScopeChange: (fn: (scope: string, scopeValue: string) => void) => void + /** Get a named SDK namespace (e.g. 'auth'). */ + get(name: 'auth'): AuthAPI + get(name: string): unknown + + // ── i18n(引擎与 locale 状态在 host 一份;插件译文自带,经 registerI18n 注册)── + /** 注册插件译文到自己的 namespace(通常 = 插件 name 段)。多次调用可 merge。 */ + registerI18n(ns: string, resources: Record>): void + /** 返回绑定 namespace 的 t(跟随当前语言);显式 `common:` 前缀经 fallbackNS 仍解析。 */ + getPluginT(ns: string): (key: string, params?: Record) => string + /** 默认(common ns)t。 */ + t(key: string, params?: Record): string + /** 当前语言码(如 'zh-CN')。 */ + getLocale(): string + /** 切语言 + 持久化 + 。 */ + setLocale(lng: string): void + /** 订阅语言变化,返回反订阅函数。 */ + subscribeLocale(cb: () => void): () => void + /** React hook:响应式读当前语言(切语言时组件重渲染)。插件 useT() 内部调用。 */ + useLocale(): string + /** locale-aware 日期格式化。 */ + formatDate(ts: number | string | Date, opts?: Intl.DateTimeFormatOptions): string + /** locale-aware 数字格式化。 */ + formatNumber(n: number, opts?: Intl.NumberFormatOptions): string +} diff --git a/web/packages/plugin-sdk/test/build.test.ts b/web/packages/plugin-sdk/test/build.test.ts new file mode 100644 index 0000000..eff4468 --- /dev/null +++ b/web/packages/plugin-sdk/test/build.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { readChartVersion } from '../src/build' + +// definePluginConfig 本身要在 tsup 配置上下文里跑(依赖 tsup 的 defineConfig), +// 这里只测它新增的、能独立验证的那部分:从 Chart.yaml 读版本号。 +// project#341:这个值此前是插件里手写的字面量,49 个插件里 48 个跟 Chart.yaml 对不上。 + +let dir: string | undefined + +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = undefined +}) + +function writeChart(content: string): string { + dir = mkdtempSync(path.join(tmpdir(), 'plugin-sdk-chart-')) + writeFileSync(path.join(dir, 'Chart.yaml'), content) + return dir +} + +describe('readChartVersion', () => { + it('读出裸写的 version', () => { + const root = writeChart('apiVersion: v2\nname: rise-plugin-demo\nversion: 0.1.151\n') + expect(readChartVersion(root, 'demo')).toBe('0.1.151') + }) + + it('读出带引号的 version(防将来手滑加引号)', () => { + const root = writeChart('apiVersion: v2\nversion: "1.2.3"\n') + expect(readChartVersion(root, 'demo')).toBe('1.2.3') + }) + + it('忽略行内注释', () => { + const root = writeChart('apiVersion: v2\nversion: 0.2.0 # bumped for foo\n') + expect(readChartVersion(root, 'demo')).toBe('0.2.0') + }) + + it('不会被别的键下面缩进的 version 骗到(如 dependencies 列表里的子 chart 版本)', () => { + const root = writeChart( + 'apiVersion: v2\n' + + 'dependencies:\n' + + ' - name: sub\n' + + ' version: 9.9.9\n' + + 'version: 0.1.0\n', + ) + expect(readChartVersion(root, 'demo')).toBe('0.1.0') + }) + + it('Chart.yaml 不存在时抛出明确错误', () => { + dir = mkdtempSync(path.join(tmpdir(), 'plugin-sdk-chart-')) + expect(() => readChartVersion(dir!, 'demo')).toThrow(/找不到.*Chart\.yaml/) + }) + + it('Chart.yaml 里没有 version 字段时抛出明确错误', () => { + const root = writeChart('apiVersion: v2\nname: rise-plugin-demo\n') + expect(() => readChartVersion(root, 'demo')).toThrow(/没有顶层 version/) + }) +}) diff --git a/web/packages/plugin-sdk/test/cluster-resource-api.test.ts b/web/packages/plugin-sdk/test/cluster-resource-api.test.ts new file mode 100644 index 0000000..79f64f7 --- /dev/null +++ b/web/packages/plugin-sdk/test/cluster-resource-api.test.ts @@ -0,0 +1,589 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + RESOURCES_FACADE_PREFIX, + RiseResourceError, + RiseResourcePathError, + buildResourcePath, + clusterViewScopeOrThrow, + resolveClusterViewScope, + scopedApi, +} from '../src/cluster-resource-api' + +const NS_PATH = RESOURCES_FACADE_PREFIX + 'namespaces/team-a/pods' +const CLUSTER_NS_PATH = '/clusters/member-1' + NS_PATH +const CORE_NS_PATH = '/api/v1/namespaces/team-a/pods' +const GROUPS_NS_PATH = '/apis/apps/v1/namespaces/team-a/deployments' +const OWN_OAPI_PATH = '/oapis/modelx.rise.io/v1alpha1/models' + +// ── buildResourcePath:通用 API 路径(三类根都接受) ───────────────────────── + +describe('buildResourcePath — 通用路径接受', () => { + it('resources façade namespaced 路径消费轴原样返回', () => { + expect(buildResourcePath(NS_PATH, { axis: 'consumption' })).toBe(NS_PATH) + }) + + it('原生 core API /api/v1 消费轴原样返回', () => { + expect(buildResourcePath(CORE_NS_PATH, { axis: 'consumption' })).toBe(CORE_NS_PATH) + }) + + it('原生 group API /apis/apps/v1 消费轴原样返回', () => { + expect(buildResourcePath(GROUPS_NS_PATH, { axis: 'consumption' })).toBe(GROUPS_NS_PATH) + }) + + it('插件自有 /oapis/{group}/{version} 消费轴原样返回', () => { + expect(buildResourcePath(OWN_OAPI_PATH, { axis: 'consumption' })).toBe(OWN_OAPI_PATH) + }) + + it('任意 group/version 组合都被接受,不限于已知资源形态', () => { + expect(buildResourcePath('/oapis/log.rise.io/v1/datasets/ds/logs', { axis: 'consumption' })).toBe( + '/oapis/log.rise.io/v1/datasets/ds/logs', + ) + expect(buildResourcePath('/oapis/tenant.theriseunion.io/v1alpha1/workspaces', { axis: 'consumption' })).toBe( + '/oapis/tenant.theriseunion.io/v1alpha1/workspaces', + ) + expect(buildResourcePath('/oapis/resources.rise.io/v2/namespaces/a/pods', { axis: 'consumption' })).toBe( + '/oapis/resources.rise.io/v2/namespaces/a/pods', + ) + }) + + it('cluster-scoped 路径(无 namespace 段)消费轴放行——语义由调用方确认', () => { + expect(buildResourcePath(RESOURCES_FACADE_PREFIX + 'nodes', { axis: 'consumption' })).toBe( + RESOURCES_FACADE_PREFIX + 'nodes', + ) + expect(buildResourcePath('/api/v1/nodes', { axis: 'consumption' })).toBe('/api/v1/nodes') + expect(buildResourcePath(RESOURCES_FACADE_PREFIX + 'namespaces', { axis: 'consumption' })).toBe( + RESOURCES_FACADE_PREFIX + 'namespaces', + ) + }) + + it('消费轴下 ns label 自动路由只对 façade 读有效——SDK 不代猜,行为差异见 README 表', () => { + // 通用化后消费轴对三类路径都发裸路径;是否按 ns label 路由数据面 + // 是后端对 resources façade 的专属行为,原生 /api /apis 走 Karmada proxy, + // 调用方需自行确认目标 API 语义。 + for (const path of [NS_PATH, CORE_NS_PATH, GROUPS_NS_PATH]) { + expect(buildResourcePath(path, { axis: 'consumption' })).not.toContain('/clusters/') + } + }) + + it('带 query 的路径原样保留 query', () => { + const withQuery = NS_PATH + '?limit=100' + expect(buildResourcePath(withQuery, { axis: 'consumption' })).toBe(withQuery) + }) +}) + +describe('buildResourcePath — 供给轴(cluster 前缀注入)', () => { + it('façade 路径加 /clusters/{c} 前缀', () => { + expect(buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'member-1' })).toBe(CLUSTER_NS_PATH) + }) + + it('原生 /api/v1 路径同样加前缀', () => { + expect(buildResourcePath(CORE_NS_PATH, { axis: 'cluster', cluster: 'member-1' })).toBe( + '/clusters/member-1' + CORE_NS_PATH, + ) + }) + + it('原生 /apis 路径同样加前缀', () => { + expect(buildResourcePath(GROUPS_NS_PATH, { axis: 'cluster', cluster: 'member-1' })).toBe( + '/clusters/member-1' + GROUPS_NS_PATH, + ) + }) + + it('插件自有 /oapis 路径同样加前缀——授权轴变化、目标 Service 不变', () => { + expect(buildResourcePath(OWN_OAPI_PATH, { axis: 'cluster', cluster: 'member-1' })).toBe( + '/clusters/member-1' + OWN_OAPI_PATH, + ) + }) + + it("host 集群同样加前缀——'host' 是真实本地平面,不是控制面回退值", () => { + expect(buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'host' })).toBe('/clusters/host' + NS_PATH) + }) + + it('带点号的集群名(DNS1123 subdomain)接受并编码', () => { + expect(buildResourcePath(CORE_NS_PATH, { axis: 'cluster', cluster: 'cluster.bj-1' })).toBe( + '/clusters/cluster.bj-1' + CORE_NS_PATH, + ) + }) + + it("保留值 '_all' 抛可识别错误,不静默切回消费轴", () => { + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: '_all' })).toThrow(RiseResourcePathError) + }) + + it('空 cluster 抛错', () => { + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: '' })).toThrow(RiseResourcePathError) + }) + + it('注入尝试(斜杠 / 空格 / %2f / 问号 / 大写 / 控制字符)抛错', () => { + for (const bad of ['a/b', 'a b', 'a%2fb', 'a?b', 'a#b', 'A-B-大写', 'a\u0000']) { + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: bad })).toThrow(RiseResourcePathError) + } + }) + + it('超长 cluster 名(>253)拒绝', () => { + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'a'.repeat(254) })).toThrow( + RiseResourcePathError, + ) + }) + + it('非法 namespace 名拒绝(含原生 /api /apis 路径与供给轴)', () => { + expect(() => + buildResourcePath(RESOURCES_FACADE_PREFIX + 'namespaces/Bad_NS/pods', { + axis: 'cluster', + cluster: 'member-1', + }), + ).toThrow(RiseResourcePathError) + expect(() => buildResourcePath('/api/v1/namespaces/Bad_NS/pods', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + expect(() => buildResourcePath('/apis/apps/v1/namespaces/Bad_NS/x', { axis: 'cluster', cluster: 'member-1' })).toThrow( + RiseResourcePathError, + ) + }) + + it('_all / _all_ 保留 namespace 段拒绝(三类路径一致)', () => { + expect(() => buildResourcePath('/api/v1/namespaces/_all/pods', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + expect(() => buildResourcePath('/apis/apps/v1/namespaces/_all_/x', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + }) +}) + +describe('buildResourcePath — path 形态边界', () => { + it('拒绝绝对 http(s) URL——不把平台凭据带出域', () => { + for (const url of [ + 'https://evil.example.com' + NS_PATH, + 'http://127.0.0.1:8080/x', + 'https://same-origin.example' + CORE_NS_PATH, + ]) { + expect(() => buildResourcePath(url, { axis: 'consumption' })).toThrow(RiseResourcePathError) + expect(() => buildResourcePath(url, { axis: 'cluster', cluster: 'member-1' })).toThrow(RiseResourcePathError) + } + }) + + it('拒绝 URL fragment,防止追加 query 落在 hash 后被忽略', () => { + expect(() => buildResourcePath(CORE_NS_PATH + '#fragment', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + }) + + it('拒绝相对协议 URL', () => { + expect(() => buildResourcePath('//evil.example.com/x', { axis: 'consumption' })).toThrow(RiseResourcePathError) + }) + + it('拒绝非 API 根前缀的路径(静态资源 / 页面路由不是 API)', () => { + const rejected = [ + '/plugins/foo/index.js', + '/login', + '/oapis', // 缺尾斜杠不算 API 根 + '/api', // 同上 + '/apis', // 同上 + '/oapisx/foo/v1', + '/v1/namespaces/a/pods', + ] + for (const path of rejected) { + expect(() => buildResourcePath(path, { axis: 'consumption' }), path).toThrow(RiseResourcePathError) + expect(() => buildResourcePath(path, { axis: 'cluster', cluster: 'member-1' }), path).toThrow( + RiseResourcePathError, + ) + } + }) + + it('拒绝已带 /clusters/ 前缀的 path(重复前缀是调用 bug)', () => { + expect(() => buildResourcePath(CLUSTER_NS_PATH, { axis: 'cluster', cluster: 'member-1' })).toThrow( + RiseResourcePathError, + ) + expect(() => buildResourcePath(CLUSTER_NS_PATH, { axis: 'consumption' })).toThrow(RiseResourcePathError) + }) + + it('拒绝不以 / 开头的相对路径', () => { + expect(() => buildResourcePath('oapis/modelx.rise.io/v1alpha1/x', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + }) + + it('scopedApi 传输入口同样拒绝非 API 根路径(不只纯函数文档承诺)', async () => { + await expect(scopedApi.get('/plugins/foo/index.js', { axis: 'consumption' })).rejects.toThrow( + RiseResourcePathError, + ) + await expect(scopedApi.get('/login', { axis: 'consumption' })).rejects.toThrow(RiseResourcePathError) + }) +}) + +// ── resolveClusterViewScope / clusterViewScopeOrThrow ─────────────────────── + +describe('resolveClusterViewScope', () => { + it('现行 /cluster/{c} URL 解析出集群名', () => { + expect(resolveClusterViewScope('/cluster/ecs-001/core.rise.io/v1/kube-infra/nodes')).toEqual({ + cluster: 'ecs-001', + }) + }) + + it('旧版 /(boss|metrics|express)/clusters/{c} URL 同样识别', () => { + expect(resolveClusterViewScope('/boss/clusters/ecs-001/some/page')).toEqual({ cluster: 'ecs-001' }) + expect(resolveClusterViewScope('/metrics/clusters/ecs-001/x')).toEqual({ cluster: 'ecs-001' }) + expect(resolveClusterViewScope('/express/clusters/ecs-001/x')).toEqual({ cluster: 'ecs-001' }) + }) + + it('workspace / project / platform 页面返回 null——不代猜 host', () => { + expect(resolveClusterViewScope('/workspace/team-a/core.rise.io/v1/tenant/overview')).toBeNull() + expect( + resolveClusterViewScope('/workspace/team-a/project/dev/core.rise.io/v1/project-infra/pods'), + ).toBeNull() + expect(resolveClusterViewScope('/core.rise.io/v1/core/users')).toBeNull() + }) + + it('非法编码或非法 DNS1123 cluster 视图返回 null,不抛原始 URIError', () => { + expect(resolveClusterViewScope('/cluster/%/x')).toBeNull() + expect(resolveClusterViewScope('/cluster/%2F/x')).toBeNull() + expect(resolveClusterViewScope('/cluster/a..b/x')).toBeNull() + }) + + it("'_all' 全部集群视图返回 null——聚合视图不是可下钻集群", () => { + expect(resolveClusterViewScope('/cluster/_all/core.rise.io/v1/kube-infra/list')).toBeNull() + expect(resolveClusterViewScope('/boss/clusters/_all/x')).toBeNull() + }) + + it('无 pathname 参数且无 window 时返回 null', () => { + expect(resolveClusterViewScope('/no-cluster-here')).toBeNull() + }) +}) + +describe('clusterViewScopeOrThrow', () => { + afterEach(() => { + delete (globalThis as any).window + }) + + it('cluster 视图(window.location 指向 /cluster/host/…)返回供给轴 scope', () => { + ;(globalThis as any).window = { + location: { pathname: '/cluster/host/core.rise.io/v1/kube-infra/nodes' }, + } + expect(clusterViewScopeOrThrow()).toEqual({ axis: 'cluster', cluster: 'host' }) + }) + + it('project URL fail-closed:抛可识别错误而不是猜 host', () => { + ;(globalThis as any).window = { + location: { pathname: '/workspace/team-a/project/dev/core.rise.io/v1/project-infra/pods' }, + } + expect(() => clusterViewScopeOrThrow()).toThrow(RiseResourcePathError) + expect(() => clusterViewScopeOrThrow()).toThrow(/不在 cluster 视图/) + }) + + it('无 window 环境同样抛错', () => { + expect(() => clusterViewScopeOrThrow()).toThrow(RiseResourcePathError) + }) +}) + +// ── 传输层 ─────────────────────────────────────────────────────────────────── + +function jsonResponse(status: number, body: unknown, statusText = ''): Response { + return new Response(body === undefined ? null : JSON.stringify(body), { + status, + statusText, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('scopedApi 传输层', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + fetchMock.mockReset() + vi.unstubAllGlobals() + }) + + it('消费轴 GET 发裸路径 + credentials include', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { items: [] })) + const res = await scopedApi.get(NS_PATH, { axis: 'consumption' }) + expect(res).toEqual({ data: { items: [] }, status: 200 }) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(NS_PATH) + expect(init.credentials).toBe('include') + }) + + it('原生 /api/v1 消费轴 GET 发裸路径', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(CORE_NS_PATH, { axis: 'consumption' }) + expect(fetchMock.mock.calls[0][0]).toBe(CORE_NS_PATH) + }) + + it('插件自有 /oapis 消费轴 GET 发裸路径', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(OWN_OAPI_PATH, { axis: 'consumption' }) + expect(fetchMock.mock.calls[0][0]).toBe(OWN_OAPI_PATH) + }) + + it('供给轴 GET 发 /clusters/{c} 前缀(façade 与原生 API 一致)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(NS_PATH, { axis: 'cluster', cluster: 'member-1' }) + expect(fetchMock.mock.calls[0][0]).toBe(CLUSTER_NS_PATH) + + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get('/api/v1/nodes', { axis: 'cluster', cluster: 'member-1' }) + expect(fetchMock.mock.calls[1][0]).toBe('/clusters/member-1/api/v1/nodes') + + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(GROUPS_NS_PATH, { axis: 'cluster', cluster: 'member-1' }) + expect(fetchMock.mock.calls[2][0]).toBe('/clusters/member-1' + GROUPS_NS_PATH) + }) + + it('host 视图 GET 发 /clusters/host——不代猜、不复刻宿主 host 裸路径分支', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(NS_PATH, { axis: 'cluster', cluster: 'host' }) + expect(fetchMock.mock.calls[0][0]).toBe('/clusters/host' + NS_PATH) + }) + + it('member 数据面写不再由 SDK 拒绝——是否可写由服务端判定(原生 API 与 façade 语义不同)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(201, { ok: true })) + const r = await scopedApi.post('/api/v1/namespaces/dev/configmaps', { axis: 'cluster', cluster: 'member-1' }, {}) + expect(r.status).toBe(201) + expect(fetchMock.mock.calls[0][0]).toBe('/clusters/member-1/api/v1/namespaces/dev/configmaps') + + // façade member 写会被服务端 rejectMemberMutation 挡(405),SDK 不预判 + fetchMock.mockResolvedValueOnce(jsonResponse(405, { message: 'member mutation rejected' })) + const err = await scopedApi + .post(NS_PATH, { axis: 'cluster', cluster: 'member-1' }, {}) + .catch((e) => e) + expect(err).toBeInstanceOf(RiseResourceError) + expect(err.status).toBe(405) + }) + + it('消费轴写放行', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(201, {})) + await scopedApi.post(NS_PATH, { axis: 'consumption' }, { metadata: {} }) + expect(fetchMock.mock.calls[0][0]).toBe(NS_PATH) + }) + + it('204 与空 body 解析为 null,不抛 SyntaxError', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) + const res = await scopedApi.delete(NS_PATH + '/pod-a', { axis: 'consumption' }) + expect(res).toEqual({ data: null, status: 204 }) + }) + + it('空 200 body 同样解析为 null', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })) + const res = await scopedApi.get(NS_PATH, { axis: 'consumption' }) + expect(res.data).toBeNull() + }) + + it('非 2xx 抛 RiseResourceError,message 从 body.message 提取', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(403, { message: 'namespaces "team-a" is forbidden' })) + const err = await scopedApi.get(NS_PATH, { axis: 'cluster', cluster: 'member-1' }).catch((e) => e) + expect(err).toBeInstanceOf(RiseResourceError) + expect(err.status).toBe(403) + expect(err.message).toContain('forbidden') + expect(err.body).toEqual({ message: 'namespaces "team-a" is forbidden' }) + expect(err.url).toBe(CLUSTER_NS_PATH) + expect(err.method).toBe('GET') + }) + + it('HTTP/2 无 statusText 时回落到状态码', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(500, null, '')) + const err = await scopedApi.get(NS_PATH, { axis: 'consumption' }).catch((e) => e) + expect(err).toBeInstanceOf(RiseResourceError) + expect(err.message).toBe('HTTP 500') + }) + + it('body.error 字段也能提取 message', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(409, { error: 'AlreadyExists' })) + const err = await scopedApi.get(NS_PATH, { axis: 'consumption' }).catch((e) => e) + expect(err.message).toBe('AlreadyExists') + }) + + it('网络失败抛 status=0 的 RiseResourceError', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')) + const err = await scopedApi.get(NS_PATH, { axis: 'consumption' }).catch((e) => e) + expect(err).toBeInstanceOf(RiseResourceError) + expect(err.status).toBe(0) + }) + + it('AbortError 原样透传(不被包装)', async () => { + const abort = new DOMException('Aborted', 'AbortError') + fetchMock.mockRejectedValueOnce(abort) + const err = await scopedApi + .get(NS_PATH, { axis: 'consumption' }, { signal: new AbortController().signal }) + .catch((e) => e) + expect(err).toBe(abort) + }) + + it('params 序列化:null/undefined/空串剔除,其余转 query', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get( + NS_PATH, + { axis: 'consumption' }, + { params: { limit: '100', continue: null, keyword: undefined, empty: '', page: 2 } }, + ) + expect(fetchMock.mock.calls[0][0]).toBe(NS_PATH + '?limit=100&page=2') + }) + + it('path 自带 query 与 params 合并(同名 params 覆盖),不产生双问号', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(NS_PATH + '?limit=10&sortBy=name', { axis: 'consumption' }, { + params: { limit: '500' }, + }) + const url = fetchMock.mock.calls[0][0] as string + expect(url.startsWith(NS_PATH + '?')).toBe(true) + expect(url.match(/\?/g)).toHaveLength(1) + expect(url).toContain('limit=500') + expect(url).toContain('sortBy=name') + }) + + it('path 已有 query 值中的第二个 ? 不被截断', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(NS_PATH + '?fieldSelector=a?b', { axis: 'consumption' }) + expect(fetchMock.mock.calls[0][0]).toBe(NS_PATH + '?fieldSelector=a%3Fb') + }) + + it('params 为空对象时不产生问号', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(NS_PATH, { axis: 'consumption' }, { params: {} }) + expect(fetchMock.mock.calls[0][0]).toBe(NS_PATH) + }) + + it('POST body 序列化为 JSON,Content-Type application/json', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(201, {})) + await scopedApi.post(NS_PATH, { axis: 'consumption' }, { metadata: { name: 'x' } }) + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.method).toBe('POST') + expect((init.headers as Record)['Content-Type']).toBe('application/json') + expect(init.body).toBe(JSON.stringify({ metadata: { name: 'x' } })) + }) + + it('PATCH 用 merge-patch Content-Type(K8s 约定)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.patch(NS_PATH + '/pod-a', { axis: 'consumption' }, { spec: {} }) + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.method).toBe('PATCH') + expect((init.headers as Record)['Content-Type']).toBe('application/merge-patch+json') + }) + + it('GET 不带 Content-Type / body', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await scopedApi.get(NS_PATH, { axis: 'consumption' }) + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.headers).toBeUndefined() + expect(init.body).toBeUndefined() + }) + + it('非 JSON 错误 body 原文进 message', async () => { + fetchMock.mockResolvedValueOnce( + new Response('plain text gateway error', { status: 502, statusText: 'Bad Gateway' }), + ) + const err = await scopedApi.get(NS_PATH, { axis: 'consumption' }).catch((e) => e) + expect(err.message).toContain('plain text gateway error') + }) +}) + +// ── 路径注入与编码防护(通用化后保持不变) ──────────────────────────────────── + +describe('buildResourcePath — dot segment 与编码注入', () => { + const TRAVERSAL = '/api/v1/namespaces/dev/pods/../../../../../../../oapis/tenant.rise.io/v1alpha1/workspaces' + + it('dot segment 逃逸 API 根 → 拒绝(消费轴与供给轴都拒绝)', () => { + // 实测 new URL(TRAVERSAL, base).pathname 会规范化掉多余段,必须在源头拒绝。 + expect(() => buildResourcePath(TRAVERSAL, { axis: 'consumption' })).toThrow(RiseResourcePathError) + expect(() => buildResourcePath(TRAVERSAL, { axis: 'cluster', cluster: 'member-1' })).toThrow( + RiseResourcePathError, + ) + }) + + it('单个 "." 段拒绝', () => { + expect(() => buildResourcePath('/api/v1/namespaces/dev/./pods', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + }) + + it('%2e / %2f / %5c 编码形态拒绝', () => { + for (const enc of ['%2e%2e', 'pods%2f..%2f..', '%2e/pods']) { + expect(() => buildResourcePath('/api/v1/namespaces/dev/' + enc, { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + } + }) + + it('控制字符拒绝', () => { + const withCtrl = '/api/v1/namespaces/dev/pods' + String.fromCharCode(10) + expect(() => buildResourcePath(withCtrl, { axis: 'consumption' })).toThrow(RiseResourcePathError) + }) + + it('裸 % / 非法百分号编码 → RiseResourcePathError 而非原始 URIError', () => { + expect(() => buildResourcePath('/api/v1/namespaces/dev/pods/%', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + expect(() => buildResourcePath('/api/v1/namespaces/dev/pods/%zz', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + }) + + it('反斜杠拒绝(浏览器按分隔符规范化,可绕过按 / 的段校验)', () => { + expect(() => buildResourcePath('/api/v1/namespaces/dev\\..\\..\\other', { axis: 'consumption' })).toThrow( + RiseResourcePathError, + ) + }) +}) + +describe('buildResourcePath — DNS1123 每段校验', () => { + it('a..b(空段)拒绝——整体正则会放过,逐段校验不会', () => { + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'a..b' })).toThrow(RiseResourcePathError) + }) + + it('a.-b / a-.b(段首尾连字符)拒绝', () => { + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'a.-b' })).toThrow(RiseResourcePathError) + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'a-.b' })).toThrow(RiseResourcePathError) + }) + + it('合法多段(cluster.bj-1.prod)接受', () => { + expect(buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'cluster.bj-1.prod' })).toBe( + '/clusters/cluster.bj-1.prod' + NS_PATH, + ) + }) + + it('超长单段(64 字符 label)拒绝', () => { + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: 'a'.repeat(64) })).toThrow( + RiseResourcePathError, + ) + }) + + it('整体超 253 拒绝(即使每段都合法)', () => { + const long = Array.from({ length: 6 }, () => 'a'.repeat(42)).join('.') + expect(long.length).toBeGreaterThan(253) + expect(() => buildResourcePath(NS_PATH, { axis: 'cluster', cluster: long })).toThrow(RiseResourcePathError) + }) +}) + +// ── 401 重定向 ─────────────────────────────────────────────────────────────── + +describe('scopedApi 401 处理', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + // 最小 window 桩:记录 location 赋值 + ;(globalThis as any).window = { + location: { pathname: '/workspace/team-a/project/dev/x', search: '?tab=pods', href: '' }, + } + }) + + afterEach(() => { + fetchMock.mockReset() + vi.unstubAllGlobals() + delete (globalThis as any).window + }) + + it('401 跳 /login 带回跳地址(镜像宿主 apiClient 语义)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(401, { message: 'Unauthorized' })) + const err = await scopedApi.get(NS_PATH, { axis: 'consumption' }).catch((e) => e) + expect(err).toBeInstanceOf(RiseResourceError) + expect(err.status).toBe(401) + const href = (globalThis as any).window.location.href as string + expect(href.startsWith('/login?redirect=')).toBe(true) + expect(decodeURIComponent(href.replace('/login?redirect=', ''))).toBe( + '/workspace/team-a/project/dev/x?tab=pods', + ) + }) +}) diff --git a/web/packages/plugin-sdk/test/core.test.ts b/web/packages/plugin-sdk/test/core.test.ts new file mode 100644 index 0000000..63110e8 --- /dev/null +++ b/web/packages/plugin-sdk/test/core.test.ts @@ -0,0 +1,550 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { PluginDefinition, PluginEventMap, RiseHostSDK } from '../src/types' + +const baseDefinition: PluginDefinition = { + name: 'test-plugin', + version: '1.0.0', + displayName: 'Test Plugin', +} + +function installHost(version = '1.0.0', extra: Partial = {}) { + const registerPlugin = vi.fn() + const host = { + VERSION: version, + registerPlugin, + registerPluginComponent: vi.fn(), + registerPluginRoutes: vi.fn(), + registerSlot: vi.fn(), + apiClient: {}, + ...extra, + } as unknown as RiseHostSDK + const runtime = globalThis as typeof globalThis & { __RISE_SDK__?: RiseHostSDK } + runtime.__RISE_SDK__ = host + return { host, registerPlugin } +} + +async function loadCreatePlugin() { + return (await import('../src/core')).createPlugin +} + +beforeEach(() => { + vi.resetModules() + vi.spyOn(console, 'log').mockImplementation(() => undefined) +}) + +afterEach(() => { + const runtime = globalThis as typeof globalThis & { __RISE_SDK__?: RiseHostSDK } + delete runtime.__RISE_SDK__ +}) + +describe('typed events', () => { + it('notifies subscribers in order and supports unsubscribe', async () => { + installHost() + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const calls: string[] = [] + const unsubscribeFirst = events.on('scope:change', () => calls.push('first')) + events.on('scope:change', () => calls.push('second')) + + events.emit('scope:change', { scope: 'cluster', value: 'c1', full: 'cluster/c1' }) + unsubscribeFirst() + events.emit('scope:change', { scope: 'cluster', value: 'c2', full: 'cluster/c2' }) + + expect(calls).toEqual(['first', 'second', 'second']) + }) + + it('runs once handlers only once', async () => { + installHost() + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const handler = vi.fn() + events.once('scope:change', handler) + + events.emit('scope:change', { scope: 'workspace', value: 'w1', full: 'workspace/w1' }) + events.emit('scope:change', { scope: 'workspace', value: 'w2', full: 'workspace/w2' }) + + expect(handler).toHaveBeenCalledOnce() + }) + + it('isolates handler failures', async () => { + installHost() + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const healthyHandler = vi.fn() + events.on('scope:change', () => { + throw new Error('broken subscriber') + }) + events.on('scope:change', healthyHandler) + + expect(() => { + events.emit('scope:change', { scope: 'platform', value: 'global', full: 'platform/global' }) + }).not.toThrow() + expect(healthyHandler).toHaveBeenCalledOnce() + expect(error).toHaveBeenCalledWith( + "[modelsphere-plugin-sdk] events handler error for 'scope:change':", + expect.any(Error), + ) + }) + + it('replays the latest snapshot to new on and once subscribers', async () => { + installHost() + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const snapshot = { scope: 'namespace' as const, value: 'ns1', full: 'namespace/ns1' } + events.emit('scope:change', snapshot) + const onHandler = vi.fn() + const onceHandler = vi.fn() + + events.on('scope:change', onHandler) + events.once('scope:change', onceHandler) + events.emit('scope:change', { scope: 'namespace', value: 'ns2', full: 'namespace/ns2' }) + + expect(onHandler).toHaveBeenNthCalledWith(1, snapshot) + expect(onHandler).toHaveBeenCalledTimes(2) + expect(onceHandler).toHaveBeenCalledOnce() + expect(onceHandler).toHaveBeenCalledWith(snapshot) + }) + + it('does not replay snapshots for non-state reserved events', async () => { + installHost() + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + events.emit('plugin:enabled', undefined) + events.emit('nav:change', undefined) + + const handler = vi.fn() + events.on('plugin:enabled', handler) + events.on('nav:change', handler) + + expect(handler).not.toHaveBeenCalled() + }) + + it('isolates async handler rejections without interrupting others', async () => { + installHost() + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const healthyHandler = vi.fn() + events.on('scope:change', async () => { + throw new Error('async broken subscriber') + }) + events.on('scope:change', healthyHandler) + + expect(() => { + events.emit('scope:change', { scope: 'platform', value: 'global', full: 'platform/global' }) + }).not.toThrow() + expect(healthyHandler).toHaveBeenCalledOnce() + + await vi.waitFor(() => { + expect(error).toHaveBeenCalledWith( + "[modelsphere-plugin-sdk] events handler error for 'scope:change':", + expect.any(Error), + ) + }) + }) + + it('bridges on/once/emit to a host-provided event bus', async () => { + const hostUnsubscribe = vi.fn() + const hostOn = vi.fn(() => hostUnsubscribe) + const hostOnce = vi.fn(() => hostUnsubscribe) + const hostEmit = vi.fn() + const { host, registerPlugin } = installHost('1.0.0', { + on: hostOn, + once: hostOnce, + emit: hostEmit, + }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const handler = vi.fn() + + const unsubscribe = events.on('scope:change', handler) + expect(hostOn).toHaveBeenCalledWith('scope:change', expect.any(Function), baseDefinition.name) + expect(unsubscribe).toBe(hostUnsubscribe) + + const onceUnsubscribe = events.once('scope:change', handler) + expect(hostOnce).toHaveBeenCalledWith('scope:change', expect.any(Function), baseDefinition.name) + expect(onceUnsubscribe).toBe(hostUnsubscribe) + + events.emit('scope:change', { scope: 'cluster', value: 'c1', full: 'cluster/c1' }) + expect(hostEmit).toHaveBeenCalledWith( + 'scope:change', + expect.objectContaining({ scope: 'cluster', value: 'c1' }), + ) + + // Host bridge must not register the plugin twice or touch the internal bus. + expect(registerPlugin).toHaveBeenCalledOnce() + expect(host.on).not.toBeUndefined() + }) + + it('isolates sync throws and async rejections delivered via the host bridge', async () => { + const hostUnsubscribe = vi.fn() + const hostOn = vi.fn((_event: string, _handler: (data: unknown) => unknown) => hostUnsubscribe) + const hostOnce = vi.fn((_event: string, _handler: (data: unknown) => unknown) => hostUnsubscribe) + const hostEmit = vi.fn() + installHost('1.0.0', { on: hostOn, once: hostOnce, emit: hostEmit }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const healthy = vi.fn() + + events.on('scope:change', () => { + throw new Error('sync broken via bridge') + }) + events.on('scope:change', async () => { + throw new Error('async broken via bridge') + }) + events.on('scope:change', healthy) + + // Host delivers an event to each wrapped subscriber; nothing may throw + // through to the host, and no unhandledRejection may surface. + const wrappedHandlers = hostOn.mock.calls.map((call) => call[1]) + expect(wrappedHandlers).toHaveLength(3) + const payload = { scope: 'cluster' as const, value: 'c1', full: 'cluster/c1' } + for (const wrapped of wrappedHandlers) { + expect(() => wrapped(payload)).not.toThrow() + } + expect(healthy).toHaveBeenCalledOnce() + + await vi.waitFor(() => { + expect(error).toHaveBeenCalledTimes(2) + }) + }) + + it('replays the local snapshot through the host bridge', async () => { + const hostUnsubscribe = vi.fn() + const hostOn = vi.fn(() => hostUnsubscribe) + const hostOnce = vi.fn(() => hostUnsubscribe) + const hostEmit = vi.fn() + installHost('1.0.0', { on: hostOn, once: hostOnce, emit: hostEmit }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const snapshot = { scope: 'namespace' as const, value: 'ns1', full: 'namespace/ns1' } + events.emit('scope:change', snapshot) + expect(hostEmit).toHaveBeenCalledWith('scope:change', snapshot) + + const onHandler = vi.fn() + events.on('scope:change', onHandler) + // Snapshot replay is SDK-side and independent of the host implementation. + expect(onHandler).toHaveBeenCalledWith(snapshot) + // Still registered with the host for future broadcasts. + expect(hostOn).toHaveBeenCalledTimes(1) + }) + + it('mirrors host broadcasts into the local snapshot for late subscribers', async () => { + let hostHandler: ((data: unknown) => unknown) | undefined + const hostOn = vi.fn((_event: string, handler: (data: unknown) => unknown) => { + hostHandler = handler + return vi.fn() + }) + installHost('1.0.0', { + on: hostOn, + once: vi.fn(), + emit: vi.fn(), + }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + events.on('scope:change', vi.fn()) + const snapshot = { scope: 'workspace' as const, value: 'ws1', full: 'workspace/ws1' } + + hostHandler?.(snapshot) + const lateHandler = vi.fn() + events.on('scope:change', lateHandler) + + expect(lateHandler).toHaveBeenCalledOnce() + expect(lateHandler).toHaveBeenCalledWith(snapshot) + }) + + it('mirrors host once broadcasts into the local snapshot for late subscribers', async () => { + let hostOnceHandler: ((data: unknown) => unknown) | undefined + const hostOnce = vi.fn((_event: string, handler: (data: unknown) => unknown) => { + hostOnceHandler = handler + return vi.fn() + }) + const hostOn = vi.fn(() => vi.fn()) + installHost('1.0.0', { on: hostOn, once: hostOnce, emit: vi.fn() }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const onceHandler = vi.fn() + events.once('scope:change', onceHandler) + const snapshot = { scope: 'workspace' as const, value: 'ws1', full: 'workspace/ws1' } + + hostOnceHandler?.(snapshot) + const lateHandler = vi.fn() + events.on('scope:change', lateHandler) + + expect(onceHandler).toHaveBeenCalledOnce() + expect(onceHandler).toHaveBeenCalledWith(snapshot) + expect(lateHandler).toHaveBeenCalledOnce() + expect(lateHandler).toHaveBeenCalledWith(snapshot) + }) + + it('replays getScope before the first host subscription', async () => { + const hostOn = vi.fn(() => vi.fn()) + const hostOnce = vi.fn(() => vi.fn()) + const current = { scope: 'workspace' as const, value: 'ws1', full: 'workspace/ws1' } + const getScope = vi.fn(() => current) + installHost('1.0.0', { + on: hostOn, + once: hostOnce, + emit: vi.fn(), + getScope, + }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const onHandler = vi.fn() + const onceHandler = vi.fn() + + events.on('scope:change', onHandler) + events.once('scope:change', onceHandler) + + expect(onHandler).toHaveBeenCalledWith(current) + expect(hostOn).toHaveBeenCalledOnce() + expect(onceHandler).toHaveBeenCalledWith(current) + expect(hostOnce).not.toHaveBeenCalled() + expect(getScope).toHaveBeenCalledTimes(2) + }) + + it('assigns bridged on and once subscriptions to the creating plugin', async () => { + const hostOn = vi.fn() + const hostOnce = vi.fn() + installHost('1.0.0', { on: hostOn, once: hostOnce, emit: vi.fn() }) + const createPlugin = await loadCreatePlugin() + const definition = { ...baseDefinition, name: 'owned-plugin' } + const events = createPlugin(definition).events + + events.on('nav:change', vi.fn()) + events.once('plugin:enabled', vi.fn()) + + expect(hostOn).toHaveBeenCalledWith('nav:change', expect.any(Function), 'owned-plugin') + expect(hostOnce).toHaveBeenCalledWith('plugin:enabled', expect.any(Function), 'owned-plugin') + }) + + it('consumes a once subscription from the local snapshot without host registration', async () => { + const hostUnsubscribe = vi.fn() + const hostOn = vi.fn(() => hostUnsubscribe) + const hostOnce = vi.fn(() => hostUnsubscribe) + const hostEmit = vi.fn() + installHost('1.0.0', { on: hostOn, once: hostOnce, emit: hostEmit }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const snapshot = { scope: 'platform' as const, value: 'global', full: 'platform/global' } + events.emit('scope:change', snapshot) + + const onceHandler = vi.fn() + events.once('scope:change', onceHandler) + expect(onceHandler).toHaveBeenCalledWith(snapshot) + expect(hostOnce).not.toHaveBeenCalled() + }) + + it('does not bridge when the host lacks a complete event bus', async () => { + const hostOn = vi.fn() + const hostOnce = vi.fn() + // No host.emit -> all-or-nothing bridging is off -> in-SDK bus fallback. + installHost('1.0.0', { on: hostOn, once: hostOnce }) + const createPlugin = await loadCreatePlugin() + const events = createPlugin(baseDefinition).events + const handler = vi.fn() + const snapshot = { scope: 'cluster' as const, value: 'c1', full: 'cluster/c1' } + + events.emit('scope:change', snapshot) + events.on('scope:change', handler) + + expect(handler).toHaveBeenCalledWith(snapshot) // internal snapshot replay worked + expect(hostOn).not.toHaveBeenCalled() // not bridged + }) +}) + +describe('registration contract validation', () => { + it('reports the plugin name and nested menu field path', async () => { + const { registerPlugin } = installHost() + const createPlugin = await loadCreatePlugin() + const invalid = { + ...baseDefinition, + name: 'broken-menu', + menus: [{ name: 'parent', path: '/parent', scope: 'console', children: [{ name: 'child', scope: 'console' }] }], + } as unknown as PluginDefinition + + expect(() => createPlugin(invalid)).toThrow("plugin 'broken-menu': menus[0].children[0].path 缺失") + expect(registerPlugin).not.toHaveBeenCalled() + }) + + it('rejects malformed scope declarations before registration', async () => { + const { registerPlugin } = installHost() + const createPlugin = await loadCreatePlugin() + const invalid = { + ...baseDefinition, + name: 'broken-scope', + scopes: [{ title: 'Missing scope' }], + } as unknown as PluginDefinition + + expect(() => createPlugin(invalid)).toThrow("plugin 'broken-scope': scopes[0].scope 缺失") + expect(registerPlugin).not.toHaveBeenCalled() + }) + + it('accepts valid menu and scope declarations', async () => { + const { registerPlugin } = installHost() + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin({ + ...baseDefinition, + scopes: [{ scope: 'workspace', title: 'Workspace' }], + menus: [{ name: 'overview', path: '/overview', scope: 'workspace', requiredScope: ['workspace'] }], + })).not.toThrow() + expect(registerPlugin).toHaveBeenCalledOnce() + }) + + it('accepts project as a required menu scope', async () => { + const { registerPlugin } = installHost() + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin({ + ...baseDefinition, + menus: [{ name: 'project', path: '/project', scope: 'project', requiredScope: ['project'] }], + })).not.toThrow() + expect(registerPlugin).toHaveBeenCalledOnce() + }) + + it('rejects a definition without a plugin name before registration', async () => { + const { registerPlugin } = installHost() + const createPlugin = await loadCreatePlugin() + const invalid = { version: '1.0.0', displayName: 'No Name' } as unknown as PluginDefinition + + expect(() => createPlugin(invalid)).toThrow("plugin '': name 缺失") + expect(registerPlugin).not.toHaveBeenCalled() + }) +}) + +describe('lifecycle context', () => { + it('uses the current host getScope payload for onMount', async () => { + const current = { scope: 'workspace' as const, value: 'ws1', full: 'workspace/ws1' } + const getScope = vi.fn(() => current) + installHost('1.0.0', { getScope }) + const createPlugin = await loadCreatePlugin() + const onMount = vi.fn() + + createPlugin({ ...baseDefinition, onMount }) + + expect(getScope).toHaveBeenCalledOnce() + expect(onMount).toHaveBeenCalledWith(expect.objectContaining({ + scope: { type: 'workspace', value: 'ws1', full: 'workspace/ws1' }, + })) + }) + + it('falls back when host getScope is missing', async () => { + installHost() + const createPlugin = await loadCreatePlugin() + const onMount = vi.fn() + + createPlugin({ ...baseDefinition, onMount }) + + expect(onMount).toHaveBeenCalledWith(expect.objectContaining({ + scope: { type: 'platform', value: 'global', full: 'platform/global' }, + })) + }) + + it('falls back when host getScope returns null', async () => { + installHost('1.0.0', { getScope: () => null }) + const createPlugin = await loadCreatePlugin() + const onMount = vi.fn() + + createPlugin({ ...baseDefinition, onMount }) + + expect(onMount).toHaveBeenCalledWith(expect.objectContaining({ + scope: { type: 'platform', value: 'global', full: 'platform/global' }, + })) + }) + + it.each([ + { scope: 'unknown', value: 'ws1', full: 'workspace/ws1' }, + { scope: 'workspace', value: '', full: 'workspace/ws1' }, + { scope: 'workspace', value: 'ws1', full: 'workspace/OTHER' }, + ])('falls back when the host getScope payload is invalid: $scope/$value', async (payload) => { + installHost('1.0.0', { + getScope: () => payload as unknown as PluginEventMap['scope:change'], + }) + const createPlugin = await loadCreatePlugin() + const onMount = vi.fn() + + createPlugin({ ...baseDefinition, onMount }) + + expect(onMount).toHaveBeenCalledWith(expect.objectContaining({ + scope: { type: 'platform', value: 'global', full: 'platform/global' }, + })) + }) + + it('falls back without throwing when host getScope throws', async () => { + installHost('1.0.0', { + getScope: () => { + throw new Error('scope unavailable') + }, + }) + const createPlugin = await loadCreatePlugin() + const onMount = vi.fn() + + expect(() => createPlugin({ ...baseDefinition, onMount })).not.toThrow() + expect(onMount).toHaveBeenCalledWith(expect.objectContaining({ + scope: { type: 'platform', value: 'global', full: 'platform/global' }, + })) + }) +}) + +describe('SDK version compatibility', () => { + it('accepts matching major versions', async () => { + const { registerPlugin } = installHost('1.9.0') + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin(baseDefinition)).not.toThrow() + expect(registerPlugin).toHaveBeenCalledOnce() + }) + + it('throws with host and SDK versions when majors differ', async () => { + const { registerPlugin } = installHost('2.0.0') + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin(baseDefinition)).toThrow('Version mismatch: host 2.0.0, SDK 1.0.1') + expect(registerPlugin).not.toHaveBeenCalled() + }) + + it('accepts host versions with a leading v prefix', async () => { + const { registerPlugin } = installHost('v1.0.0') + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin(baseDefinition)).not.toThrow() + expect(registerPlugin).toHaveBeenCalledOnce() + }) + + it('accepts prerelease host versions with the same major', async () => { + const { registerPlugin } = installHost('v1.0.0-beta') + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin(baseDefinition)).not.toThrow() + expect(registerPlugin).toHaveBeenCalledOnce() + }) + + it('throws for v-prefixed versions whose major differs', async () => { + const { registerPlugin } = installHost('v2.0.0') + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin(baseDefinition)).toThrow('Version mismatch: host v2.0.0, SDK 1.0.1') + expect(registerPlugin).not.toHaveBeenCalled() + }) + + it('rejects the real legacy host version shape before registration', async () => { + const { registerPlugin } = installHost('0.1.0') + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin(baseDefinition)).toThrow(/Version mismatch: host 0\.1\.0/) + expect(registerPlugin).not.toHaveBeenCalled() + }) + + it('fails open when the host declares no version', async () => { + const { host, registerPlugin } = installHost() + delete host.VERSION + const createPlugin = await loadCreatePlugin() + + expect(() => createPlugin(baseDefinition)).not.toThrow() + expect(registerPlugin).toHaveBeenCalledOnce() + }) +}) diff --git a/web/packages/plugin-sdk/test/dev.test.ts b/web/packages/plugin-sdk/test/dev.test.ts new file mode 100644 index 0000000..e6ce3a8 --- /dev/null +++ b/web/packages/plugin-sdk/test/dev.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { createServer } from 'vite' +import { definePluginDevConfig } from '../src/dev' +import { developmentSharedModules } from '../src/dev-shared' + +const directories: string[] = [] +afterEach(() => { directories.splice(0).forEach(directory => rmSync(directory, { recursive: true, force: true })) }) + +function fixture() { + const root = mkdtempSync(path.join(os.tmpdir(), 'modelsphere-dev-')) + directories.push(root) + mkdirSync(path.join(root, 'src')) + mkdirSync(path.join(root, 'web/src'), { recursive: true }) + writeFileSync(path.join(root, 'src/runtime.tsx'), 'export const component = "fixture"') + writeFileSync(path.join(root, 'Chart.yaml'), 'version: 1.2.3\n') + writeFileSync(path.join(root, 'web/src/index.css'), '') + return root +} + +it('transforms the bootstrap and keeps all development requests inside the plugin proxy', async () => { + const root = fixture() + const config = definePluginDevConfig({ name: 'example', root, hostSource: root, hostOrigin: 'http://127.0.0.1:5173' }) + // The source preset is under test; published presets resolve compiled .mjs shims. + const aliases = config.resolve?.alias + if (Array.isArray(aliases)) for (const alias of aliases) alias.replacement = alias.replacement.replace(/-shim\.mjs$/, '-shim.ts') + const server = await createServer({ ...config, configFile: false, optimizeDeps: { ...config.optimizeDeps, noDiscovery: true, include: [] }, server: { middlewareMode: true } }) + try { + const result = await server.transformRequest('/plugins/example/__modelsphere_entry.ts') + expect(result?.code).toContain('http://127.0.0.1:5173/@react-refresh') + expect(result?.code).toContain('/plugins/example/@vite/client') + expect(result?.code).toContain('/plugins/example/src/runtime.tsx') + expect(result?.code).toContain('/plugins/example/@fs/') + } finally { await server.close() } +}) + +it('configures a same-origin WebSocket and injects the chart version', () => { + const root = fixture() + const config = definePluginDevConfig({ name: 'example', root, hostSource: root, hostOrigin: 'https://console.example.test' }) + expect(config.server?.hmr).toMatchObject({ host: 'console.example.test', protocol: 'wss', clientPort: 443, path: '__hmr' }) + expect(config.define?.__PLUGIN_VERSION__).toBe('"1.2.3"') + expect(config.server?.cors).toEqual({ origin: 'https://console.example.test' }) +}) + +it('updates shared exports and invalidates their cached modules before refreshing a page', async () => { + const root = fixture() + const directory = path.join(root, '.modelsphere') + mkdirSync(directory) + const shared = developmentSharedModules(root, directory) + const cachedModule = { id: 'cached-router' } + const invalidated: unknown[] = [] + writeFileSync(path.join(root, 'src/runtime.tsx'), 'import { Link } from "react-router"; export const component = Link') + const context = { file: path.join(root, 'src/runtime.tsx'), timestamp: 123, modules: [], server: { moduleGraph: { + getModulesByFile: () => new Set([cachedModule]), invalidateModule: (module: unknown) => invalidated.push(module), + } } } + const hook = shared.plugin.handleHotUpdate + if (typeof hook !== 'function') throw new Error('Missing shared-module HMR hook') + await hook.call({} as never, context as never) + expect(readFileSync(path.join(directory, 'react-router.mjs'), 'utf8')).toContain('export const Link=shared.Link') + expect(invalidated).toContain(cachedModule) +}) diff --git a/web/packages/plugin-sdk/test/host-global.test.ts b/web/packages/plugin-sdk/test/host-global.test.ts new file mode 100644 index 0000000..1f1a243 --- /dev/null +++ b/web/packages/plugin-sdk/test/host-global.test.ts @@ -0,0 +1,12 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { getHostSDK, getBusiness } from '../src/host' + +afterEach(() => vi.unstubAllGlobals()) + +it('prefers the ModelSphere host and retains the Global compatibility host', () => { + const host = { React: {}, business: { console: { marker: 'modelsphere' } } } + vi.stubGlobal('__MODELSPHERE_SDK__', host) + vi.stubGlobal('__RISE_SDK__', { React: {}, business: {} }) + expect(getHostSDK()).toBe(host) + expect(getBusiness()).toBe(host.business) +}) diff --git a/web/packages/plugin-sdk/test/host-surface.test.ts b/web/packages/plugin-sdk/test/host-surface.test.ts new file mode 100644 index 0000000..268e7ce --- /dev/null +++ b/web/packages/plugin-sdk/test/host-surface.test.ts @@ -0,0 +1,72 @@ +// 宿主注入面的访问器与守卫(freeland#2060): +// - getUi / getBusiness 读 window.__RISE_SDK__.ui / .business,宿主未就绪时返回空对象;usePermissions 转发宿主 hook。 +// - 本包不含业务组件:src/ 下没有 business/,入口不导出业务组件名与已移除的 getComponents(业务组件只在 rise-global +// @theriseunion/business,插件经 getBusiness() 取用)。 +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as SDK from '../src/index' +import { getBusiness, getUi, usePermissions } from '../src/host' + +const SRC = join(dirname(fileURLToPath(import.meta.url)), '../src') +const host = globalThis as { __RISE_SDK__?: unknown } + +afterEach(() => { delete host.__RISE_SDK__ }) + +describe('访问器', () => { + it('getUi / getBusiness 返回宿主注入的对应面(同一引用)', () => { + const ui = { Button: () => null } + const business = { SpecSelect: () => null } + host.__RISE_SDK__ = { ui, business } + expect(getUi()).toBe(ui) + expect(getBusiness()).toBe(business) + }) + + it('宿主未就绪或缺面时返回空对象,不抛', () => { + expect(getUi()).toEqual({}) + expect(getBusiness()).toEqual({}) + host.__RISE_SDK__ = {} + expect(getUi()).toEqual({}) + expect(getBusiness()).toEqual({}) + }) + + it('usePermissions 转发宿主 hook 并原样返回', () => { + const value = { hasPermission: () => true, loading: false } + const hook = vi.fn(() => value) + host.__RISE_SDK__ = { usePermissions: hook } + expect(usePermissions()).toBe(value) + expect(hook).toHaveBeenCalledTimes(1) + }) + + it('宿主没有 usePermissions 时抛出带名字的错误', () => { + host.__RISE_SDK__ = {} + expect(() => usePermissions()).toThrow(/usePermissions is not provided by the host/) + }) +}) + +describe('守卫:本包不含业务组件', () => { + const BUSINESS = [ + 'WorkspaceSelect', 'ProjectSelect', 'ClusterSelect', 'SpecSelect', 'SpecSelectView', 'PoolSelect', 'PoolSelectView', + 'ImageSelect', 'ImageSelectView', 'useFlavorChoices', 'useImageChoices', 'flavorChoicesErrorText', 'prepareImagePullSecrets', + 'fetchNamespaces', 'fetchPlatformRepos', 'fetchTags', 'preparePublicRepository', 'imageRefOf', 'mergeFilter', + 'repositoriesForArch', 'repositoriesMatchingFilter', 'tagOptionsOf', 'toggleFacet', 'versionsForArch', + ] + + it('src/ 下没有 business/ 目录', () => { + expect(existsSync(join(SRC, 'business'))).toBe(false) + }) + + it('入口不导出业务组件名与 getComponents', () => { + const exported = Object.keys(SDK) + expect(exported.filter(n => BUSINESS.includes(n) || n === 'getComponents')).toEqual([]) + expect(exported).toEqual(expect.arrayContaining(['getUi', 'getBusiness', 'usePermissions'])) + }) + + it('源码不引用 @theriseunion/*(发布产物不能依赖消费方装不到的包)', () => { + const code = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') + for (const f of ['index.ts', 'host.ts', 'types.ts']) { + expect(code(readFileSync(join(SRC, f), 'utf8')), f).not.toMatch(/from\s+['"]@theriseunion\//) + } + }) +}) diff --git a/web/packages/plugin-sdk/test/jsx-host.test.ts b/web/packages/plugin-sdk/test/jsx-host.test.ts new file mode 100644 index 0000000..97cfe6b --- /dev/null +++ b/web/packages/plugin-sdk/test/jsx-host.test.ts @@ -0,0 +1,21 @@ +import { afterEach, expect, it, vi } from 'vitest' + +afterEach(() => { vi.unstubAllGlobals(); vi.resetModules() }) + +it('forwards native JSX runtime arguments to the host including static children metadata', async () => { + const jsx = vi.fn() + const jsxDEV = vi.fn() + vi.stubGlobal('__MODELSPHERE_SDK__', { React: { Fragment: Symbol('fragment') }, ReactJSXRuntime: { jsx, jsxs: jsx }, ReactJSXDevRuntime: { jsxDEV } }) + const runtime = await import('../src/react-jsx-runtime-shim') + expect(runtime.jsx).toBe(jsx) + expect(runtime.jsxs).toBe(jsx) + expect(runtime.jsxDEV).toBe(jsxDEV) +}) + +it('retains createElement fallback for existing Global hosts', async () => { + const createElement = vi.fn() + vi.stubGlobal('__RISE_SDK__', { React: { Fragment: Symbol('fragment'), createElement } }) + const runtime = await import('../src/react-jsx-runtime-shim') + runtime.jsx('div', { children: 'content' }, 'key') + expect(createElement).toHaveBeenCalledWith('div', { children: 'content', key: 'key' }) +}) diff --git a/web/packages/plugin-sdk/test/plugin-instance-types.test.ts b/web/packages/plugin-sdk/test/plugin-instance-types.test.ts new file mode 100644 index 0000000..b4f1efb --- /dev/null +++ b/web/packages/plugin-sdk/test/plugin-instance-types.test.ts @@ -0,0 +1,36 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { expect, it } from 'vitest' + +it('消费者仍可使用标准注入面,但旧句柄字段和类型不再可引用', () => { + const root = fileURLToPath(new URL('../', import.meta.url)) + const dir = mkdtempSync(join(root, '.instance-type-contract-')) + const file = join(dir, 'consumer.ts') + writeFileSync(file, ` + import { getUi, getBusiness } from '../src/index' + import type { PluginInstance } from '../src/index' + // @ts-expect-error 4.0 删除旧公开类型。 + import type { PluginUI } from '../src/index' + declare const plugin: PluginInstance + // @ts-expect-error 4.0 删除旧句柄字段。 + plugin.ui + plugin.api + plugin.events + plugin.nav + getUi().ConfirmDialog + getBusiness().SpecSelect + `) + try { + const program = ts.createProgram([file], { + target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, skipLibCheck: true, + strict: true, noEmit: true, esModuleInterop: true, + }) + const diagnostics = ts.getPreEmitDiagnostics(program) + expect(diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/web/packages/plugin-sdk/test/plugin-instance.test.ts b/web/packages/plugin-sdk/test/plugin-instance.test.ts new file mode 100644 index 0000000..e37a885 --- /dev/null +++ b/web/packages/plugin-sdk/test/plugin-instance.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPlugin } from '../src/core' +import { getBusiness, getUi } from '../src/host' +import { createMockSDK } from '../src/testing' + +afterEach(() => { delete (globalThis as any).__RISE_SDK__ }) + +describe('PluginInstance 4.0', () => { + it('注册插件时保留其他句柄能力,不再暴露旧 UI 字段', () => { + const ui = { toast: { add: vi.fn() }, ConfirmDialog: () => null, Sheet: () => null } + const business = { SpecSelect: () => null } + const api = { get: vi.fn() } + const registerPlugin = vi.fn() + ;(globalThis as any).__RISE_SDK__ = { VERSION: '1.0.1', ui, business, apiClient: api, registerPlugin } + + const plugin = createPlugin({ name: 'contract-test', version: '1.0.0', displayName: 'Contract test' }) + expect(registerPlugin).toHaveBeenCalledOnce() + expect(plugin.name).toBe('contract-test') + expect(plugin.api).toBe(api) + expect(typeof plugin.events.on).toBe('function') + expect(typeof plugin.nav.addMenu).toBe('function') + expect(Object.prototype.hasOwnProperty.call(plugin, 'ui')).toBe(false) + expect(getUi()).toBe(ui) + expect(getBusiness()).toBe(business) + expect((globalThis as any).__RISE_SDK__.VERSION).toBe('1.0.1') + }) + + it('测试替身完成注册,但不提供已经删除的 UI 空字段', () => { + const mock = createMockSDK() + const plugin = mock.createPlugin({ name: 'mock-contract-test', version: '1.0.0' }) as Record + expect(mock.registeredPlugins).toHaveLength(1) + expect(plugin.name).toBe('mock-contract-test') + expect(Object.prototype.hasOwnProperty.call(plugin, 'ui')).toBe(false) + }) +}) diff --git a/web/packages/plugin-sdk/test/react-jsx-runtime-shim.test.ts b/web/packages/plugin-sdk/test/react-jsx-runtime-shim.test.ts new file mode 100644 index 0000000..dc2c2ec --- /dev/null +++ b/web/packages/plugin-sdk/test/react-jsx-runtime-shim.test.ts @@ -0,0 +1,67 @@ +/* +`react/jsx-runtime` shim。 + +为什么值得测:它是**所有插件共用的基础设施**,而且坏起来很难查 —— jsx 转发写错 +不会报错,只会让某个依赖库渲染出错误的树(key 丢了 → 列表 diff 出问题,children +位置错了 → 内容不见)。这些症状离 shim 都很远。 +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** 装一个假 React,只记录 createElement 收到了什么。 */ +function installFakeReact() { + const createElement = vi.fn((type: any, props: any) => ({ type, props })) + ;(globalThis as any).__RISE_SDK__ = { React: { createElement, Fragment: Symbol('Fragment') } } + return createElement +} + +describe('react/jsx-runtime shim', () => { + let createElement: ReturnType + + beforeEach(() => { + vi.resetModules() + createElement = installFakeReact() + }) + afterEach(() => { + delete (globalThis as any).__RISE_SDK__ + }) + + it('把 config 原样转给 createElement(children 留在 props 里)', async () => { + const { jsx } = await import('../src/react-jsx-runtime-shim') + jsx('div', { className: 'a', children: 'hi' }) + expect(createElement).toHaveBeenCalledWith('div', { className: 'a', children: 'hi' }) + }) + + it('第三个参数是 key,要并进 props —— 丢了会让列表 diff 出错', async () => { + const { jsx } = await import('../src/react-jsx-runtime-shim') + jsx('li', { children: 'x' }, 'k1') + expect(createElement).toHaveBeenCalledWith('li', { children: 'x', key: 'k1' }) + }) + + it('没有 key 时不要凭空造一个 key 字段', async () => { + const { jsx } = await import('../src/react-jsx-runtime-shim') + jsx('div', { children: 'x' }) + const [, props] = createElement.mock.calls[0] + expect('key' in (props as object)).toBe(false) + }) + + it('jsxs 与 jsx 同语义(差别只是 children 已知是数组)', async () => { + const { jsx, jsxs } = await import('../src/react-jsx-runtime-shim') + expect(jsxs).toBe(jsx) + }) + + it('导出 jsxDEV —— 某个库显式 import 时不该炸成 No matching export', async () => { + const m = await import('../src/react-jsx-runtime-shim') + expect(typeof m.jsxDEV).toBe('function') + }) + + it('Fragment 取自宿主的 React,不是自造的', async () => { + const { Fragment } = await import('../src/react-jsx-runtime-shim') + expect(Fragment).toBe((globalThis as any).__RISE_SDK__.React.Fragment) + }) + + it('宿主没有 React 时当场报错,而不是留一个 undefined 到处传', async () => { + delete (globalThis as any).__RISE_SDK__ + vi.resetModules() + await expect(import('../src/react-jsx-runtime-shim')).rejects.toThrow(/React not found/) + }) +}) diff --git a/web/packages/plugin-sdk/tsconfig.json b/web/packages/plugin-sdk/tsconfig.json new file mode 100644 index 0000000..8abad42 --- /dev/null +++ b/web/packages/plugin-sdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist" + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/web/packages/plugin-sdk/tsup.config.ts b/web/packages/plugin-sdk/tsup.config.ts new file mode 100644 index 0000000..f27cd62 --- /dev/null +++ b/web/packages/plugin-sdk/tsup.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'tsup' + +export default defineConfig([ + // ESM + CJS for npm consumers + { + entry: { + index: 'src/index.ts', + testing: 'src/testing.ts', + // 构建预设(Node 环境,tsup 配置上下文用) + build: 'src/build.ts', + dev: 'src/dev.ts', + // react-shim(浏览器,供构建预设 alias 进插件 bundle) + 'react-shim': 'src/react-shim.ts', + // react/jsx-runtime 的 shim。**必须单独出一个产物** —— esbuild 的 alias 是 + // 前缀替换,靠 react-shim 兜不住这个子路径,详见该文件头部。 + 'react-jsx-runtime-shim': 'src/react-jsx-runtime-shim.ts', + }, + format: ['esm', 'cjs'], + dts: { entry: { index: 'src/index.ts', testing: 'src/testing.ts', build: 'src/build.ts', dev: 'src/dev.ts' } }, + clean: true, + sourcemap: true, + shims: true, + external: ['tsup'], + }, + // UMD global for Helm plugin JS bundles (loaded via + diff --git a/web/test/plugin-runtime.tsx b/web/test/plugin-runtime.tsx new file mode 100644 index 0000000..e3f7211 --- /dev/null +++ b/web/test/plugin-runtime.tsx @@ -0,0 +1,32 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { modules as builtins } from "../src/modules"; +import { Sidebar } from "../src/shell/Sidebar"; +import { navGroups } from "../src/shell/module"; +import { loadPlugins } from "../src/shell/plugins"; +import "../src/index.css"; + +const result = await loadPlugins(builtins); +if (result.errors.length) throw new Error(result.errors.join("\n")); +const plugin = result.modules.find((module) => !builtins.some((builtin) => builtin.id === module.id)); +if (!plugin) throw new Error("Configure a plugin before running this fixture"); +const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +function NavigationFixture() { + const [collapsed, setCollapsed] = useState(false); + return
+ +
+ {plugin!.pages.map((page) => )} +
+
; +} +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web/vite.config.ts b/web/vite.config.ts index 26220f7..06786e6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -49,7 +49,7 @@ export default defineConfig(({ mode }) => { const proxy: Record = swiss ? // swissd's own login: its session cookie is host-only and plain-http here, so it rides the proxy. { "/api": via(remote) } - : { "/api": via(consoleTarget), "/oauth": via(consoleTarget) }; + : { "/api": via(consoleTarget), "/oauth": via(consoleTarget), "/plugins": { ...via(consoleTarget), ws: true } }; return { root: swiss ? path.join(swissDir, "standalone") : undefined, plugins: [react(), tailwindcss(), ...(swiss ? [] : [keepDistEmbeddable()])], diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..ad7e970 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,6 @@ +import { configDefaults, defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config.ts"; + +export default defineConfig(environment => mergeConfig(viteConfig(environment), { + test: { exclude: [...configDefaults.exclude, "packages/plugin-sdk/**"] }, +}));