From 48720408f064510b3fe287e0585fb01178ba76d3 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 11 May 2026 14:47:05 +0900 Subject: [PATCH 1/2] feat: extract devframe into standalone monorepo Imports the devframe code from vite-devtools and converts the single-package boilerplate into a multi-package monorepo (packages, examples, docs, tests, skills). Sets up pnpm catalogs, Turborepo pipeline, ESLint config, tsconfig path aliases via alias.ts, and the VitePress docs site. The files-inspector example has its Vite DevTools kit integration removed (kit package lives outside this repo). --- .gitignore | 6 + README.md | 59 +- alias.ts | 53 + docs/.vitepress/config.ts | 103 + docs/errors/DF0006.md | 21 + docs/errors/DF0007.md | 21 + docs/errors/DF0008.md | 21 + docs/errors/DF0012.md | 21 + docs/errors/DF0013.md | 21 + docs/errors/DF0014.md | 53 + docs/errors/DF0015.md | 37 + docs/errors/DF0016.md | 25 + docs/errors/DF0017.md | 31 + docs/errors/DF0019.md | 52 + docs/errors/DF0020.md | 56 + docs/errors/DF0021.md | 25 + docs/errors/DF0022.md | 21 + docs/errors/DF0023.md | 21 + docs/errors/DF0024.md | 22 + docs/errors/DF0025.md | 21 + docs/errors/DF0026.md | 31 + docs/errors/DF0027.md | 21 + docs/errors/DF0028.md | 21 + docs/errors/DF0029.md | 25 + docs/errors/DF0030.md | 23 + docs/errors/DF0031.md | 36 + docs/errors/DF0032.md | 22 + docs/errors/DF0033.md | 29 + docs/errors/index.md | 44 + docs/guide/adapters.md | 310 ++ docs/guide/agent-native.md | 131 + docs/guide/client.md | 208 + docs/guide/devframe-definition.md | 196 + docs/guide/diagnostics.md | 160 + docs/guide/index.md | 159 + docs/guide/nuxt.md | 135 + docs/guide/rpc.md | 272 + docs/guide/shared-state.md | 148 + docs/guide/standalone-cli.md | 326 ++ docs/guide/streaming.md | 238 + docs/guide/utilities.md | 150 + docs/guide/when-clauses.md | 213 + docs/index.md | 41 + docs/package.json | 17 + eslint.config.js | 15 +- examples/devframe-counter/bin.mjs | 14 + examples/devframe-counter/package.json | 18 + .../devframe-counter/src/client/index.html | 13 + examples/devframe-counter/src/client/main.ts | 21 + examples/devframe-counter/src/devframe.ts | 77 + examples/devframe-files-inspector/.gitignore | 3 + examples/devframe-files-inspector/README.md | 31 + examples/devframe-files-inspector/bin.mjs | 14 + .../devframe-files-inspector/package.json | 30 + .../src/client/app.tsx | 88 + .../src/client/index.html | 13 + .../src/client/main.tsx | 7 + .../src/client/routes/about.tsx | 30 + .../src/client/routes/home.tsx | 42 + .../src/client/vite.config.ts | 15 + .../devframe-files-inspector/src/devframe.ts | 39 + .../devframe-files-inspector/tests/_utils.ts | 103 + .../tests/dev-server.test.ts | 68 + .../tests/static-build.test.ts | 111 + .../tests/static-serve.test.ts | 131 + .../devframe-files-inspector/tsconfig.json | 14 + examples/devframe-streaming-chat/README.md | 72 + examples/devframe-streaming-chat/bin.mjs | 14 + examples/devframe-streaming-chat/package.json | 28 + .../src/client/app.tsx | 271 + .../src/client/index.html | 177 + .../src/client/main.tsx | 7 + .../src/client/vite.config.ts | 15 + .../devframe-streaming-chat/src/devframe.ts | 216 + .../devframe-streaming-chat/tests/_utils.ts | 77 + .../tests/streaming-chat.test.ts | 210 + .../devframe-streaming-chat/tsconfig.json | 14 + netlify.toml | 6 + package.json | 64 +- packages/devframe/LICENSE.md | 21 + packages/devframe/README.md | 85 + packages/devframe/package.json | 131 + .../src/adapters/__tests__/dev.test.ts | 106 + .../src/adapters/__tests__/flags.test.ts | 71 + packages/devframe/src/adapters/_shared.ts | 22 + packages/devframe/src/adapters/build.ts | 125 + packages/devframe/src/adapters/cli.ts | 138 + packages/devframe/src/adapters/dev.ts | 203 + packages/devframe/src/adapters/embedded.ts | 20 + packages/devframe/src/adapters/flags.ts | 104 + packages/devframe/src/adapters/mcp.ts | 19 + packages/devframe/src/adapters/vite.ts | 145 + packages/devframe/src/client/index.ts | 16 + .../devframe/src/client/rpc-shared-state.ts | 129 + packages/devframe/src/client/rpc-static.ts | 33 + packages/devframe/src/client/rpc-streaming.ts | 192 + packages/devframe/src/client/rpc-ws.ts | 152 + packages/devframe/src/client/rpc.ts | 328 ++ .../devframe/src/client/static-rpc.test.ts | 173 + packages/devframe/src/client/static-rpc.ts | 161 + packages/devframe/src/constants.ts | 17 + packages/devframe/src/define.ts | 4 + packages/devframe/src/index.ts | 3 + .../src/node/__tests__/host-agent.test.ts | 272 + .../src/node/__tests__/host-functions.test.ts | 169 + .../__tests__/rpc-agent-introspection.test.ts | 70 + .../src/node/__tests__/rpc-streaming.test.ts | 369 ++ .../src/node/__tests__/static-dump.test.ts | 219 + .../src/node/__tests__/storage.test.ts | 43 + .../devframe/src/node/__tests__/utils.test.ts | 16 + packages/devframe/src/node/auth/index.ts | 2 + packages/devframe/src/node/auth/revoke.ts | 52 + packages/devframe/src/node/auth/state.ts | 87 + packages/devframe/src/node/context.ts | 72 + packages/devframe/src/node/diagnostics.ts | 72 + packages/devframe/src/node/host-agent.ts | 251 + .../devframe/src/node/host-diagnostics.ts | 37 + packages/devframe/src/node/host-functions.ts | 86 + packages/devframe/src/node/host-h3.ts | 54 + packages/devframe/src/node/host-views.ts | 24 + packages/devframe/src/node/index.ts | 13 + .../devframe/src/node/internal/context.ts | 109 + packages/devframe/src/node/internal/index.ts | 26 + .../src/node/mcp/__tests__/mcp-server.test.ts | 131 + .../node/mcp/__tests__/to-json-schema.test.ts | 53 + .../devframe/src/node/mcp/build-server.ts | 302 ++ .../devframe/src/node/mcp/to-json-schema.ts | 81 + packages/devframe/src/node/mcp/transports.ts | 14 + .../devframe/src/node/rpc-shared-state.ts | 133 + packages/devframe/src/node/rpc-streaming.ts | 378 ++ .../src/node/rpc/agent-invoke-tool.ts | 13 + .../src/node/rpc/agent-list-resources.ts | 15 + .../devframe/src/node/rpc/agent-list-tools.ts | 15 + .../src/node/rpc/agent-read-resource.ts | 15 + packages/devframe/src/node/rpc/index.ts | 29 + packages/devframe/src/node/server.ts | 150 + packages/devframe/src/node/static-dump.ts | 140 + packages/devframe/src/node/storage.ts | 47 + packages/devframe/src/node/utils.ts | 16 + .../recipes/__tests__/open-helpers.test.ts | 24 + packages/devframe/src/recipes/open-helpers.ts | 66 + .../src/rpc/__snapshots__/dumps.test.ts.snap | 316 ++ packages/devframe/src/rpc/cache.test.ts | 21 + packages/devframe/src/rpc/cache.ts | 54 + packages/devframe/src/rpc/client.ts | 21 + packages/devframe/src/rpc/collector.test.ts | 101 + packages/devframe/src/rpc/collector.ts | 102 + packages/devframe/src/rpc/define.ts | 29 + packages/devframe/src/rpc/diagnostics.ts | 49 + packages/devframe/src/rpc/dumps.test.ts | 922 ++++ packages/devframe/src/rpc/dumps.ts | 282 ++ packages/devframe/src/rpc/handler.ts | 48 + packages/devframe/src/rpc/index.ts | 8 + .../devframe/src/rpc/serialization.test.ts | 85 + packages/devframe/src/rpc/serialization.ts | 96 + packages/devframe/src/rpc/server.ts | 21 + .../devframe/src/rpc/transports/ws-client.ts | 101 + .../devframe/src/rpc/transports/ws-server.ts | 162 + .../devframe/src/rpc/transports/ws.test.ts | 55 + packages/devframe/src/rpc/types.test.ts | 129 + packages/devframe/src/rpc/types.ts | 330 ++ packages/devframe/src/rpc/utils.ts | 24 + packages/devframe/src/rpc/validation.ts | 31 + packages/devframe/src/types/agent.ts | 183 + packages/devframe/src/types/context.ts | 68 + packages/devframe/src/types/devframe.ts | 159 + packages/devframe/src/types/diagnostics.ts | 71 + packages/devframe/src/types/events.ts | 79 + packages/devframe/src/types/host.ts | 46 + packages/devframe/src/types/index.ts | 10 + packages/devframe/src/types/rpc-augments.ts | 100 + packages/devframe/src/types/rpc.ts | 184 + packages/devframe/src/types/utils.ts | 7 + packages/devframe/src/types/views.ts | 12 + packages/devframe/src/utils/colors.ts | 40 + packages/devframe/src/utils/events.ts | 56 + packages/devframe/src/utils/hash.ts | 8 + packages/devframe/src/utils/human-id.ts | 9 + packages/devframe/src/utils/launch-editor.ts | 14 + packages/devframe/src/utils/nanoid.ts | 10 + packages/devframe/src/utils/open.ts | 18 + packages/devframe/src/utils/promise.ts | 17 + .../devframe/src/utils/serve-static.test.ts | 209 + packages/devframe/src/utils/serve-static.ts | 216 + .../devframe/src/utils/shared-state.test.ts | 343 ++ packages/devframe/src/utils/shared-state.ts | 118 + .../src/utils/streaming-channel.test.ts | 236 + .../devframe/src/utils/streaming-channel.ts | 390 ++ .../devframe/src/utils/structured-clone.ts | 36 + packages/devframe/src/utils/when.ts | 69 + packages/devframe/tsconfig.json | 7 + packages/devframe/tsdown.config.ts | 91 + packages/nuxt/package.json | 42 + packages/nuxt/src/index.ts | 142 + packages/nuxt/src/runtime/plugin.client.ts | 19 + packages/nuxt/src/runtime/types.d.ts | 20 + packages/nuxt/tsconfig.json | 7 + packages/nuxt/tsdown.config.ts | 24 + pnpm-lock.yaml | 4481 +++++++++++++++-- pnpm-workspace.yaml | 73 +- skills/devframe/README.md | 13 + skills/devframe/SKILL.md | 428 ++ skills/devframe/templates/counter-devframe.ts | 24 + skills/devframe/templates/spa-devframe.ts | 28 + skills/devframe/templates/vite-client.ts | 11 + src/index.ts | 2 - .../tsnapi/devframe/index.snapshot.d.ts | 7 - test/api-snapshot.test.ts | 14 - .../@devframes/nuxt/index.snapshot.d.ts | 20 + .../tsnapi/@devframes/nuxt/index.snapshot.js | 7 + .../nuxt/runtime/plugin.client.snapshot.d.ts | 7 + .../nuxt/runtime/plugin.client.snapshot.js | 7 + .../devframe/adapters/build.snapshot.d.ts | 15 + .../devframe/adapters/build.snapshot.js | 6 + .../devframe/adapters/cli.snapshot.d.ts | 29 + .../tsnapi/devframe/adapters/cli.snapshot.js | 8 + .../devframe/adapters/dev.snapshot.d.ts | 28 + .../tsnapi/devframe/adapters/dev.snapshot.js | 7 + .../devframe/adapters/embedded.snapshot.d.ts | 12 + .../devframe/adapters/embedded.snapshot.js | 6 + .../devframe/adapters/mcp.snapshot.d.ts | 21 + .../tsnapi/devframe/adapters/mcp.snapshot.js | 6 + .../devframe/adapters/spa.snapshot.d.ts | 14 + .../tsnapi/devframe/adapters/spa.snapshot.js | 6 + .../devframe/adapters/vite.snapshot.d.ts | 30 + .../tsnapi/devframe/adapters/vite.snapshot.js | 6 + .../tsnapi/devframe/client.snapshot.d.ts | 67 + .../tsnapi/devframe/client.snapshot.js | 12 + .../tsnapi/devframe/constants.snapshot.d.ts | 14 + .../tsnapi/devframe/constants.snapshot.js | 14 + .../tsnapi/devframe/index.snapshot.d.ts | 61 + .../tsnapi/devframe/index.snapshot.js | 3 +- .../tsnapi/devframe/node.snapshot.d.ts | 125 + .../tsnapi/devframe/node.snapshot.js | 21 + .../tsnapi/devframe/node/auth.snapshot.d.ts | 27 + .../tsnapi/devframe/node/auth.snapshot.js | 16 + .../devframe/node/internal.snapshot.d.ts | 15 + .../tsnapi/devframe/node/internal.snapshot.js | 15 + .../recipes/open-helpers.snapshot.d.ts | 64 + .../devframe/recipes/open-helpers.snapshot.js | 8 + .../tsnapi/devframe/rpc.snapshot.d.ts | 43 + .../tsnapi/devframe/rpc.snapshot.js | 18 + .../tsnapi/devframe/rpc/client.snapshot.d.ts | 9 + .../tsnapi/devframe/rpc/client.snapshot.js | 6 + .../tsnapi/devframe/rpc/server.snapshot.d.ts | 8 + .../tsnapi/devframe/rpc/server.snapshot.js | 6 + .../rpc/transports/ws-client.snapshot.d.ts | 7 + .../rpc/transports/ws-client.snapshot.js | 6 + .../rpc/transports/ws-server.snapshot.d.ts | 8 + .../rpc/transports/ws-server.snapshot.js | 6 + .../tsnapi/devframe/types.snapshot.d.ts | 57 + .../tsnapi/devframe/types.snapshot.js | 7 + .../devframe/utils/colors.snapshot.d.ts | 25 + .../tsnapi/devframe/utils/colors.snapshot.js | 6 + .../devframe/utils/events.snapshot.d.ts | 6 + .../tsnapi/devframe/utils/events.snapshot.js | 6 + .../tsnapi/devframe/utils/hash.snapshot.d.ts | 6 + .../tsnapi/devframe/utils/hash.snapshot.js | 6 + .../devframe/utils/human-id.snapshot.d.ts | 6 + .../devframe/utils/human-id.snapshot.js | 6 + .../utils/launch-editor.snapshot.d.ts | 6 + .../devframe/utils/launch-editor.snapshot.js | 6 + .../devframe/utils/nanoid.snapshot.d.ts | 6 + .../tsnapi/devframe/utils/nanoid.snapshot.js | 6 + .../tsnapi/devframe/utils/open.snapshot.d.ts | 12 + .../tsnapi/devframe/utils/open.snapshot.js | 6 + .../devframe/utils/promise.snapshot.d.ts | 10 + .../tsnapi/devframe/utils/promise.snapshot.js | 6 + .../devframe/utils/serve-static.snapshot.d.ts | 14 + .../devframe/utils/serve-static.snapshot.js | 7 + .../devframe/utils/shared-state.snapshot.d.ts | 15 + .../devframe/utils/shared-state.snapshot.js | 6 + .../utils/streaming-channel.snapshot.d.ts | 14 + .../utils/streaming-channel.snapshot.js | 7 + .../utils/structured-clone.snapshot.d.ts | 9 + .../utils/structured-clone.snapshot.js | 9 + .../tsnapi/devframe/utils/when.snapshot.d.ts | 24 + .../tsnapi/devframe/utils/when.snapshot.js | 7 + tests/exports.test.ts | 14 + tsconfig.base.json | 121 + tsconfig.json | 16 +- tsdown.config.ts | 14 - turbo.json | 24 + vitest.config.ts | 7 + 284 files changed, 23602 insertions(+), 614 deletions(-) create mode 100644 alias.ts create mode 100644 docs/.vitepress/config.ts create mode 100644 docs/errors/DF0006.md create mode 100644 docs/errors/DF0007.md create mode 100644 docs/errors/DF0008.md create mode 100644 docs/errors/DF0012.md create mode 100644 docs/errors/DF0013.md create mode 100644 docs/errors/DF0014.md create mode 100644 docs/errors/DF0015.md create mode 100644 docs/errors/DF0016.md create mode 100644 docs/errors/DF0017.md create mode 100644 docs/errors/DF0019.md create mode 100644 docs/errors/DF0020.md create mode 100644 docs/errors/DF0021.md create mode 100644 docs/errors/DF0022.md create mode 100644 docs/errors/DF0023.md create mode 100644 docs/errors/DF0024.md create mode 100644 docs/errors/DF0025.md create mode 100644 docs/errors/DF0026.md create mode 100644 docs/errors/DF0027.md create mode 100644 docs/errors/DF0028.md create mode 100644 docs/errors/DF0029.md create mode 100644 docs/errors/DF0030.md create mode 100644 docs/errors/DF0031.md create mode 100644 docs/errors/DF0032.md create mode 100644 docs/errors/DF0033.md create mode 100644 docs/errors/index.md create mode 100644 docs/guide/adapters.md create mode 100644 docs/guide/agent-native.md create mode 100644 docs/guide/client.md create mode 100644 docs/guide/devframe-definition.md create mode 100644 docs/guide/diagnostics.md create mode 100644 docs/guide/index.md create mode 100644 docs/guide/nuxt.md create mode 100644 docs/guide/rpc.md create mode 100644 docs/guide/shared-state.md create mode 100644 docs/guide/standalone-cli.md create mode 100644 docs/guide/streaming.md create mode 100644 docs/guide/utilities.md create mode 100644 docs/guide/when-clauses.md create mode 100644 docs/index.md create mode 100644 docs/package.json create mode 100755 examples/devframe-counter/bin.mjs create mode 100644 examples/devframe-counter/package.json create mode 100644 examples/devframe-counter/src/client/index.html create mode 100644 examples/devframe-counter/src/client/main.ts create mode 100644 examples/devframe-counter/src/devframe.ts create mode 100644 examples/devframe-files-inspector/.gitignore create mode 100644 examples/devframe-files-inspector/README.md create mode 100755 examples/devframe-files-inspector/bin.mjs create mode 100644 examples/devframe-files-inspector/package.json create mode 100644 examples/devframe-files-inspector/src/client/app.tsx create mode 100644 examples/devframe-files-inspector/src/client/index.html create mode 100644 examples/devframe-files-inspector/src/client/main.tsx create mode 100644 examples/devframe-files-inspector/src/client/routes/about.tsx create mode 100644 examples/devframe-files-inspector/src/client/routes/home.tsx create mode 100644 examples/devframe-files-inspector/src/client/vite.config.ts create mode 100644 examples/devframe-files-inspector/src/devframe.ts create mode 100644 examples/devframe-files-inspector/tests/_utils.ts create mode 100644 examples/devframe-files-inspector/tests/dev-server.test.ts create mode 100644 examples/devframe-files-inspector/tests/static-build.test.ts create mode 100644 examples/devframe-files-inspector/tests/static-serve.test.ts create mode 100644 examples/devframe-files-inspector/tsconfig.json create mode 100644 examples/devframe-streaming-chat/README.md create mode 100755 examples/devframe-streaming-chat/bin.mjs create mode 100644 examples/devframe-streaming-chat/package.json create mode 100644 examples/devframe-streaming-chat/src/client/app.tsx create mode 100644 examples/devframe-streaming-chat/src/client/index.html create mode 100644 examples/devframe-streaming-chat/src/client/main.tsx create mode 100644 examples/devframe-streaming-chat/src/client/vite.config.ts create mode 100644 examples/devframe-streaming-chat/src/devframe.ts create mode 100644 examples/devframe-streaming-chat/tests/_utils.ts create mode 100644 examples/devframe-streaming-chat/tests/streaming-chat.test.ts create mode 100644 examples/devframe-streaming-chat/tsconfig.json create mode 100644 netlify.toml create mode 100644 packages/devframe/LICENSE.md create mode 100644 packages/devframe/README.md create mode 100644 packages/devframe/package.json create mode 100644 packages/devframe/src/adapters/__tests__/dev.test.ts create mode 100644 packages/devframe/src/adapters/__tests__/flags.test.ts create mode 100644 packages/devframe/src/adapters/_shared.ts create mode 100644 packages/devframe/src/adapters/build.ts create mode 100644 packages/devframe/src/adapters/cli.ts create mode 100644 packages/devframe/src/adapters/dev.ts create mode 100644 packages/devframe/src/adapters/embedded.ts create mode 100644 packages/devframe/src/adapters/flags.ts create mode 100644 packages/devframe/src/adapters/mcp.ts create mode 100644 packages/devframe/src/adapters/vite.ts create mode 100644 packages/devframe/src/client/index.ts create mode 100644 packages/devframe/src/client/rpc-shared-state.ts create mode 100644 packages/devframe/src/client/rpc-static.ts create mode 100644 packages/devframe/src/client/rpc-streaming.ts create mode 100644 packages/devframe/src/client/rpc-ws.ts create mode 100644 packages/devframe/src/client/rpc.ts create mode 100644 packages/devframe/src/client/static-rpc.test.ts create mode 100644 packages/devframe/src/client/static-rpc.ts create mode 100644 packages/devframe/src/constants.ts create mode 100644 packages/devframe/src/define.ts create mode 100644 packages/devframe/src/index.ts create mode 100644 packages/devframe/src/node/__tests__/host-agent.test.ts create mode 100644 packages/devframe/src/node/__tests__/host-functions.test.ts create mode 100644 packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts create mode 100644 packages/devframe/src/node/__tests__/rpc-streaming.test.ts create mode 100644 packages/devframe/src/node/__tests__/static-dump.test.ts create mode 100644 packages/devframe/src/node/__tests__/storage.test.ts create mode 100644 packages/devframe/src/node/__tests__/utils.test.ts create mode 100644 packages/devframe/src/node/auth/index.ts create mode 100644 packages/devframe/src/node/auth/revoke.ts create mode 100644 packages/devframe/src/node/auth/state.ts create mode 100644 packages/devframe/src/node/context.ts create mode 100644 packages/devframe/src/node/diagnostics.ts create mode 100644 packages/devframe/src/node/host-agent.ts create mode 100644 packages/devframe/src/node/host-diagnostics.ts create mode 100644 packages/devframe/src/node/host-functions.ts create mode 100644 packages/devframe/src/node/host-h3.ts create mode 100644 packages/devframe/src/node/host-views.ts create mode 100644 packages/devframe/src/node/index.ts create mode 100644 packages/devframe/src/node/internal/context.ts create mode 100644 packages/devframe/src/node/internal/index.ts create mode 100644 packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts create mode 100644 packages/devframe/src/node/mcp/__tests__/to-json-schema.test.ts create mode 100644 packages/devframe/src/node/mcp/build-server.ts create mode 100644 packages/devframe/src/node/mcp/to-json-schema.ts create mode 100644 packages/devframe/src/node/mcp/transports.ts create mode 100644 packages/devframe/src/node/rpc-shared-state.ts create mode 100644 packages/devframe/src/node/rpc-streaming.ts create mode 100644 packages/devframe/src/node/rpc/agent-invoke-tool.ts create mode 100644 packages/devframe/src/node/rpc/agent-list-resources.ts create mode 100644 packages/devframe/src/node/rpc/agent-list-tools.ts create mode 100644 packages/devframe/src/node/rpc/agent-read-resource.ts create mode 100644 packages/devframe/src/node/rpc/index.ts create mode 100644 packages/devframe/src/node/server.ts create mode 100644 packages/devframe/src/node/static-dump.ts create mode 100644 packages/devframe/src/node/storage.ts create mode 100644 packages/devframe/src/node/utils.ts create mode 100644 packages/devframe/src/recipes/__tests__/open-helpers.test.ts create mode 100644 packages/devframe/src/recipes/open-helpers.ts create mode 100644 packages/devframe/src/rpc/__snapshots__/dumps.test.ts.snap create mode 100644 packages/devframe/src/rpc/cache.test.ts create mode 100644 packages/devframe/src/rpc/cache.ts create mode 100644 packages/devframe/src/rpc/client.ts create mode 100644 packages/devframe/src/rpc/collector.test.ts create mode 100644 packages/devframe/src/rpc/collector.ts create mode 100644 packages/devframe/src/rpc/define.ts create mode 100644 packages/devframe/src/rpc/diagnostics.ts create mode 100644 packages/devframe/src/rpc/dumps.test.ts create mode 100644 packages/devframe/src/rpc/dumps.ts create mode 100644 packages/devframe/src/rpc/handler.ts create mode 100644 packages/devframe/src/rpc/index.ts create mode 100644 packages/devframe/src/rpc/serialization.test.ts create mode 100644 packages/devframe/src/rpc/serialization.ts create mode 100644 packages/devframe/src/rpc/server.ts create mode 100644 packages/devframe/src/rpc/transports/ws-client.ts create mode 100644 packages/devframe/src/rpc/transports/ws-server.ts create mode 100644 packages/devframe/src/rpc/transports/ws.test.ts create mode 100644 packages/devframe/src/rpc/types.test.ts create mode 100644 packages/devframe/src/rpc/types.ts create mode 100644 packages/devframe/src/rpc/utils.ts create mode 100644 packages/devframe/src/rpc/validation.ts create mode 100644 packages/devframe/src/types/agent.ts create mode 100644 packages/devframe/src/types/context.ts create mode 100644 packages/devframe/src/types/devframe.ts create mode 100644 packages/devframe/src/types/diagnostics.ts create mode 100644 packages/devframe/src/types/events.ts create mode 100644 packages/devframe/src/types/host.ts create mode 100644 packages/devframe/src/types/index.ts create mode 100644 packages/devframe/src/types/rpc-augments.ts create mode 100644 packages/devframe/src/types/rpc.ts create mode 100644 packages/devframe/src/types/utils.ts create mode 100644 packages/devframe/src/types/views.ts create mode 100644 packages/devframe/src/utils/colors.ts create mode 100644 packages/devframe/src/utils/events.ts create mode 100644 packages/devframe/src/utils/hash.ts create mode 100644 packages/devframe/src/utils/human-id.ts create mode 100644 packages/devframe/src/utils/launch-editor.ts create mode 100644 packages/devframe/src/utils/nanoid.ts create mode 100644 packages/devframe/src/utils/open.ts create mode 100644 packages/devframe/src/utils/promise.ts create mode 100644 packages/devframe/src/utils/serve-static.test.ts create mode 100644 packages/devframe/src/utils/serve-static.ts create mode 100644 packages/devframe/src/utils/shared-state.test.ts create mode 100644 packages/devframe/src/utils/shared-state.ts create mode 100644 packages/devframe/src/utils/streaming-channel.test.ts create mode 100644 packages/devframe/src/utils/streaming-channel.ts create mode 100644 packages/devframe/src/utils/structured-clone.ts create mode 100644 packages/devframe/src/utils/when.ts create mode 100644 packages/devframe/tsconfig.json create mode 100644 packages/devframe/tsdown.config.ts create mode 100644 packages/nuxt/package.json create mode 100644 packages/nuxt/src/index.ts create mode 100644 packages/nuxt/src/runtime/plugin.client.ts create mode 100644 packages/nuxt/src/runtime/types.d.ts create mode 100644 packages/nuxt/tsconfig.json create mode 100644 packages/nuxt/tsdown.config.ts create mode 100644 skills/devframe/README.md create mode 100644 skills/devframe/SKILL.md create mode 100644 skills/devframe/templates/counter-devframe.ts create mode 100644 skills/devframe/templates/spa-devframe.ts create mode 100644 skills/devframe/templates/vite-client.ts delete mode 100644 src/index.ts delete mode 100644 test/__snapshots__/tsnapi/devframe/index.snapshot.d.ts delete mode 100644 test/api-snapshot.test.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/constants.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts rename {test => tests}/__snapshots__/tsnapi/devframe/index.snapshot.js (65%) create mode 100644 tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/node.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/types.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.js create mode 100644 tests/exports.test.ts create mode 100644 tsconfig.base.json delete mode 100644 tsdown.config.ts create mode 100644 turbo.json diff --git a/.gitignore b/.gitignore index 95f9ee9..26e9c15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,17 @@ .cache .DS_Store +.eslintcache .idea *.log *.tgz +*.tsbuildinfo coverage dist lib-cov logs node_modules temp +.turbo +.vitepress/cache +.vitepress/dist +packages/devframe/skills diff --git a/README.md b/README.md index 82b3304..498a46e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# devframe +# Devframe [![npm version][npm-version-src]][npm-version-href] [![npm downloads][npm-downloads-src]][npm-downloads-href] @@ -6,7 +6,58 @@ [![JSDocs][jsdocs-src]][jsdocs-href] [![License][license-src]][license-href] -_description_ +Framework-neutral foundation for building generic DevTools. Describe one devframe — its RPC, its data, its SPA, its CLI shape — and deploy the same definition through any of seven adapters. + +Documentation: [https://devfra.me/](https://devfra.me/). + +## Install + +```sh +pnpm add devframe +``` + +## Hello, Devframe + +```ts +import { defineDevframe, defineRpcFunction } from 'devframe' +import { createCli } from 'devframe/adapters/cli' + +const devframe = defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'my-devframe:hello', + type: 'static', + jsonSerializable: true, + handler: () => ({ message: 'hello' }), + })) + }, +}) + +await createCli(devframe).parse() +``` + +## Adapters + +| Adapter | Use case | +|---------|----------| +| `cli` | Standalone CLI tool with `dev` / `build` / `mcp` subcommands. | +| `build` | Generates a static, self-contained SPA snapshot. | +| `vite` | Runs as a Vite plugin alongside the host app's dev server. | +| `kit` | Mounts into a DevTools Kit aggregator (e.g. `@vitejs/devtools-kit`). | +| `embedded` | Overlays inside another devframe's UI. | +| `mcp` | Surfaces the devframe's RPC to coding agents over MCP. | + +## Repo layout + +| Path | Description | +|------|-------------| +| [`packages/devframe`](./packages/devframe) | The published [`devframe`](https://www.npmjs.com/package/devframe) npm package. | +| [`packages/nuxt`](./packages/nuxt) | The [`@devframes/nuxt`](https://www.npmjs.com/package/@devframes/nuxt) Nuxt module adapter. | +| [`docs`](./docs) | VitePress documentation site, deployed at https://devfra.me/. | +| [`examples`](./examples) | End-to-end demos: [`devframe-counter`](./examples/devframe-counter), [`devframe-files-inspector`](./examples/devframe-files-inspector), and [`devframe-streaming-chat`](./examples/devframe-streaming-chat). | +| [`tests`](./tests) | Public-API snapshot tests via [`tsnapi`](https://github.com/posva/tsnapi). | ## Sponsors @@ -18,7 +69,7 @@ _description_ ## License -[MIT](./LICENSE) License © [Anthony Fu](https://github.com/antfu) +[MIT](./LICENSE.md) License © [Anthony Fu](https://github.com/antfu) @@ -29,6 +80,6 @@ _description_ [bundle-src]: https://img.shields.io/bundlephobia/minzip/devframe?style=flat&colorA=080f12&colorB=1fa669&label=minzip [bundle-href]: https://bundlephobia.com/result?p=devframe [license-src]: https://img.shields.io/github/license/devframes/devframe.svg?style=flat&colorA=080f12&colorB=1fa669 -[license-href]: https://github.com/devframes/devframe/blob/main/LICENSE +[license-href]: https://github.com/devframes/devframe/blob/main/LICENSE.md [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669 [jsdocs-href]: https://www.jsdocs.io/package/devframe diff --git a/alias.ts b/alias.ts new file mode 100644 index 0000000..37ff564 --- /dev/null +++ b/alias.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { join, relative } from 'pathe' + +const root = fileURLToPath(new URL('.', import.meta.url)) +const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.meta.url)) + +export const alias = { + 'devframe/rpc/transports/ws-server': r('devframe/src/rpc/transports/ws-server.ts'), + 'devframe/rpc/transports/ws-client': r('devframe/src/rpc/transports/ws-client.ts'), + 'devframe/rpc/client': r('devframe/src/rpc/client.ts'), + 'devframe/rpc/server': r('devframe/src/rpc/server.ts'), + 'devframe/rpc': r('devframe/src/rpc'), + 'devframe/types': r('devframe/src/types/index.ts'), + 'devframe/node/auth': r('devframe/src/node/auth/index.ts'), + 'devframe/node/internal': r('devframe/src/node/internal/index.ts'), + 'devframe/node': r('devframe/src/node/index.ts'), + 'devframe/constants': r('devframe/src/constants.ts'), + 'devframe/utils/colors': r('devframe/src/utils/colors.ts'), + 'devframe/utils/events': r('devframe/src/utils/events.ts'), + 'devframe/utils/hash': r('devframe/src/utils/hash.ts'), + 'devframe/utils/human-id': r('devframe/src/utils/human-id.ts'), + 'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'), + 'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'), + 'devframe/utils/open': r('devframe/src/utils/open.ts'), + 'devframe/utils/promise': r('devframe/src/utils/promise.ts'), + 'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'), + 'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'), + 'devframe/utils/streaming-channel': r('devframe/src/utils/streaming-channel.ts'), + 'devframe/utils/structured-clone': r('devframe/src/utils/structured-clone.ts'), + 'devframe/utils/when': r('devframe/src/utils/when.ts'), + 'devframe/adapters/cli': r('devframe/src/adapters/cli.ts'), + 'devframe/adapters/dev': r('devframe/src/adapters/dev.ts'), + 'devframe/adapters/build': r('devframe/src/adapters/build.ts'), + 'devframe/adapters/vite': r('devframe/src/adapters/vite.ts'), + 'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'), + 'devframe/adapters/mcp': r('devframe/src/adapters/mcp.ts'), + '@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'), + '@devframes/nuxt': r('nuxt/src/index.ts'), + 'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'), + 'devframe/client': r('devframe/src/client/index.ts'), + 'devframe': r('devframe/src'), +} + +// update tsconfig.base.json +const raw = fs.readFileSync(join(root, 'tsconfig.base.json'), 'utf-8').trim() +const tsconfig = JSON.parse(raw) +tsconfig.compilerOptions.paths = Object.fromEntries( + Object.entries(alias).map(([key, value]) => [key, [`./${relative(root, value)}`]]), +) +const newRaw = JSON.stringify(tsconfig, null, 2) +if (newRaw !== raw) + fs.writeFileSync(join(root, 'tsconfig.base.json'), `${newRaw}\n`, 'utf-8') diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..36ac6b8 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,103 @@ +import type { DefaultTheme } from 'vitepress' +import { fileURLToPath } from 'node:url' +import { globSync } from 'tinyglobby' +import { defineConfig } from 'vitepress' +import { withMermaid } from 'vitepress-plugin-mermaid' + +const errorsDir = fileURLToPath(new URL('../errors/', import.meta.url)) + +function listErrorCodes(prefix: string): string[] { + return globSync(`${prefix}*.md`, { cwd: errorsDir }) + .map(f => f.replace(/\.md$/, '')) + .sort() +} + +function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] { + return [ + { text: 'Introduction', link: `${prefix}/guide/` }, + { text: 'Devframe Definition', link: `${prefix}/guide/devframe-definition` }, + { text: 'Adapters', link: `${prefix}/guide/adapters` }, + { text: 'RPC', link: `${prefix}/guide/rpc` }, + { text: 'Shared State', link: `${prefix}/guide/shared-state` }, + { text: 'Streaming', link: `${prefix}/guide/streaming` }, + { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, + { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, + { text: 'Utilities', link: `${prefix}/guide/utilities` }, + { text: 'Client', link: `${prefix}/guide/client` }, + { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, + { text: 'Nuxt Helper', link: `${prefix}/guide/nuxt` }, + { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, + ] +} + +export function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] { + return [ + { + text: 'Guide', + items: guideItems(prefix), + }, + { + text: 'Error Reference', + link: `${prefix}/errors/`, + collapsed: true, + items: listErrorCodes('DF').map(code => ({ + text: code, + link: `${prefix}/errors/${code}`, + })), + }, + ] +} + +export function devframeNav(prefix = ''): DefaultTheme.NavItemWithLink[] { + return [ + ...guideItems(prefix), + { text: 'Error Reference', link: `${prefix}/errors/` }, + ] +} + +export default withMermaid(defineConfig({ + title: 'Devframe', + description: 'Framework-neutral foundation for building generic DevTools — RPC layer, hosts, and adapters.', + themeConfig: { + nav: [ + { text: 'Guide', items: guideItems('') }, + { text: 'Error Reference', link: '/errors/' }, + ], + sidebar: devframeSidebar(), + search: { + provider: 'local', + }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/devframes/devframe' }, + ], + editLink: { + pattern: 'https://github.com/devframes/devframe/edit/main/docs/:path', + text: 'Suggest changes to this page', + }, + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright © 2025-present Anthony Fu & Contributors', + }, + lastUpdated: { + text: 'Last updated', + }, + }, + mermaid: { + theme: 'base', + flowchart: { + curve: 'basis', + padding: 20, + nodeSpacing: 50, + rankSpacing: 60, + useMaxWidth: true, + }, + sequence: { + actorMargin: 80, + boxMargin: 10, + boxTextMargin: 5, + noteMargin: 10, + messageMargin: 40, + useMaxWidth: true, + }, + }, +})) diff --git a/docs/errors/DF0006.md b/docs/errors/DF0006.md new file mode 100644 index 0000000..fa848d0 --- /dev/null +++ b/docs/errors/DF0006.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0006: RPC Function Not Registered + +## Message + +> RPC function "`{name}`" is not registered + +## Cause + +`RpcFunctionsHost.invokeLocal()` was called with a method name that has not been registered on this host. + +## Fix + +Register the function with `ctx.rpc.register(defineRpcFunction({ name }))` before invoking it. + +## Source + +- [`packages/devframe/src/node/host-functions.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-functions.ts) — `RpcFunctionsHost.invokeLocal()` throws `DF0006` when the requested method has not been registered on this host. diff --git a/docs/errors/DF0007.md b/docs/errors/DF0007.md new file mode 100644 index 0000000..7cc5e40 --- /dev/null +++ b/docs/errors/DF0007.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0007: AsyncLocalStorage Not Set + +## Message + +> AsyncLocalStorage is not set, it likely to be an internal bug of the DevTools foundation + +## Cause + +`getCurrentRpcSession()` was called outside the RPC dispatch context. Usually indicates the RPC server hasn't been composed with `startHttpAndWs` or the caller is running before the async context is established. + +## Fix + +Only call `getCurrentRpcSession()` from RPC handlers executed by the server. Report as a bug if you hit this inside a handler. + +## Source + +- [`packages/devframe/src/node/host-functions.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-functions.ts) — `getCurrentRpcSession()` throws `DF0007` when called outside the RPC dispatch async context. diff --git a/docs/errors/DF0008.md b/docs/errors/DF0008.md new file mode 100644 index 0000000..57db674 --- /dev/null +++ b/docs/errors/DF0008.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0008: View distDir Not Found + +## Message + +> distDir `{distDir}` does not exist + +## Cause + +`DevToolsViewHost.hostStatic()` was asked to mount a directory that doesn't exist on disk. + +## Fix + +Verify the `distDir` path resolves correctly (run your SPA build first, and check the resolved absolute path). + +## Source + +- [`packages/devframe/src/node/host-views.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-views.ts) — `DevToolsViewHost.hostStatic()` throws `DF0008` when the resolved `distDir` does not exist on disk. diff --git a/docs/errors/DF0012.md b/docs/errors/DF0012.md new file mode 100644 index 0000000..d4f5ae6 --- /dev/null +++ b/docs/errors/DF0012.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0012: Storage Parse Failed + +## Message + +> Failed to parse storage file: `{filepath}`, falling back to defaults. + +## Cause + +The persisted storage file (e.g. `auth.json`) could not be parsed as JSON. Storage falls back to the initial value so the devtool can continue. + +## Fix + +Delete the file to reset to defaults, or investigate how it became malformed. + +## Source + +- [`packages/devframe/src/node/storage.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/storage.ts) — `createStorage()` catches `JSON.parse` errors and logs `DF0012` (with the cause attached) before falling back to `initialValue`. diff --git a/docs/errors/DF0013.md b/docs/errors/DF0013.md new file mode 100644 index 0000000..00a94fd --- /dev/null +++ b/docs/errors/DF0013.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0013: Shared State Not Found + +## Message + +> Shared state of "`{key}`" is not found, please provide an initial value for the first time + +## Cause + +`RpcSharedStateHost.get()` was called for a key that has no stored state yet, and no `initialValue` was passed. + +## Fix + +Pass `initialValue` on the first call: `ctx.rpc.sharedState.get(key, { initialValue: ... })`. + +## Source + +- [`packages/devframe/src/node/rpc-shared-state.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/rpc-shared-state.ts) — `RpcSharedStateHost.get()` throws `DF0013` when neither an existing entry nor an `initialValue` is provided for a key. diff --git a/docs/errors/DF0014.md b/docs/errors/DF0014.md new file mode 100644 index 0000000..bef3792 --- /dev/null +++ b/docs/errors/DF0014.md @@ -0,0 +1,53 @@ +--- +outline: deep +--- + +# DF0014: Invalid Agent Field + +::: warning Experimental +The agent-native surface is experimental and may change without a major version bump until it stabilizes. +::: + +## Message + +> RPC function "`{name}`" has an invalid `agent` field — `description` must be a non-empty string. + +## Cause + +An RPC function was defined with an `agent` field (opting it in for exposure to agents via the MCP adapter), but the required `description` property is missing or empty. + +Agents rely on the description to decide when to invoke a tool. Empty or placeholder descriptions would produce unusable agent surface. + +## Example + +```ts +defineRpcFunction({ + name: 'rolldown-get-session-summary', + type: 'query', + agent: { + description: '', // ❌ empty + }, + // ... +}) +``` + +## Fix + +Provide a non-empty `description` (~1–3 sentences) explaining what the tool does and when agents should invoke it: + +```ts +defineRpcFunction({ + name: 'rolldown-get-session-summary', + type: 'query', + agent: { + description: 'Summarize a Rolldown build session by its id. Safe to call freely.', + }, + // ... +}) +``` + +If you didn't intend for this function to be agent-exposed, remove the `agent` field entirely (default-deny). + +## Source + +- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — agent registration throws `DF0014` when a tool's `agent.description` is missing or empty. diff --git a/docs/errors/DF0015.md b/docs/errors/DF0015.md new file mode 100644 index 0000000..35ae3e5 --- /dev/null +++ b/docs/errors/DF0015.md @@ -0,0 +1,37 @@ +--- +outline: deep +--- + +# DF0015: Agent Tool Already Registered + +::: warning Experimental +The agent-native surface is experimental and may change without a major version bump until it stabilizes. +::: + +## Message + +> Agent tool "`{id}`" is already registered. + +## Cause + +`ctx.agent.registerTool()` was called with an `id` that collides with: + +- an already-registered agent tool, or +- an RPC function whose `agent` field already exposes it under the same id. + +Agent tool ids must be unique across both sources so that `invoke(id, args)` has an unambiguous target. + +## Fix + +Pick a distinct id or unregister the existing tool before re-registering: + +```ts +const handle = ctx.agent.registerTool({ id: 'my-tool', /* ... */ }) +// later... +handle.unregister() +ctx.agent.registerTool({ id: 'my-tool', /* new config */ }) +``` + +## Source + +- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — `ctx.agent.registerTool()` throws `DF0015` when the tool id collides with an existing agent tool or an RPC function's `agent` exposure. diff --git a/docs/errors/DF0016.md b/docs/errors/DF0016.md new file mode 100644 index 0000000..1fce205 --- /dev/null +++ b/docs/errors/DF0016.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0016: Agent Resource Already Registered + +::: warning Experimental +The agent-native surface is experimental and may change without a major version bump until it stabilizes. +::: + +## Message + +> Agent resource "`{id}`" is already registered. + +## Cause + +`ctx.agent.registerResource()` was called with an `id` that already exists on the host. + +## Fix + +Pick a distinct id or unregister the existing resource first via the handle returned from the previous call. + +## Source + +- [`packages/devframe/src/node/host-agent.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/host-agent.ts) — `ctx.agent.registerResource()` throws `DF0016` when the resource id is already registered on the host. diff --git a/docs/errors/DF0017.md b/docs/errors/DF0017.md new file mode 100644 index 0000000..d7877e2 --- /dev/null +++ b/docs/errors/DF0017.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# DF0017: MCP Server Start Failure + +::: warning Experimental +The agent-native surface is experimental and may change without a major version bump until it stabilizes. +::: + +## Message + +> Failed to start MCP server (`{transport}`): `{reason}` + +## Cause + +`createMcpServer()` failed while initializing. Common reasons: + +- `@modelcontextprotocol/sdk` is not installed. This is a peer dependency — add it to your devtool's dependencies. +- The selected transport is not yet implemented (the first devframe MCP release ships with `stdio` only; `http` is planned). +- The underlying transport threw during `connect()` (e.g. stdin/stdout is not available). + +## Fix + +- **Missing SDK**: `pnpm add @modelcontextprotocol/sdk` (or npm/yarn equivalent) in the package that imports `devframe/adapters/mcp`. +- **Unsupported transport**: pass `{ transport: 'stdio' }` until HTTP support lands. +- **Transport init failure**: check the underlying error (attached as `cause`) for specifics. + +## Source + +- [`packages/devframe/src/node/mcp/build-server.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/mcp/build-server.ts) — `createMcpServer()` throws `DF0017` when the requested transport is unsupported or when the underlying transport fails to `connect()`. diff --git a/docs/errors/DF0019.md b/docs/errors/DF0019.md new file mode 100644 index 0000000..e084aaf --- /dev/null +++ b/docs/errors/DF0019.md @@ -0,0 +1,52 @@ +--- +outline: deep +--- + +# DF0019: Agent Requires JSON-Serializable RPC + +## Message + +> RPC function "`{name}`" has `agent` set but `jsonSerializable` is not `true` — MCP requires JSON-serializable data. + +## Cause + +The `agent` field exposes an RPC function as an MCP tool. MCP and the underlying schema-conversion path (`@valibot/to-json-schema`) only consume JSON-shaped data. Functions whose payloads can include `Map`, `Set`, `Date`, `BigInt`, circular references, or class instances cannot be safely advertised to agents. + +A registered function is rejected when `agent` is present and `jsonSerializable` is not explicitly `true`. + +## Example + +```ts +defineRpcFunction({ + name: 'my-plugin:summary', + agent: { description: 'Returns a summary' }, + // missing `jsonSerializable: true` → registration throws DF0019 + handler: () => ({ items: [1, 2, 3] }), +}) +``` + +## Fix + +Either declare the payload as JSON-safe: + +```ts +defineRpcFunction({ + name: 'my-plugin:summary', + jsonSerializable: true, + agent: { description: 'Returns a summary' }, + handler: () => ({ items: [1, 2, 3] }), +}) +``` + +Or remove `agent` to keep the function as an internal RPC (no agent exposure): + +```ts +defineRpcFunction({ + name: 'my-plugin:summary', + handler: () => new Map([['a', 1]]), +}) +``` + +## Source + +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.register()` throws `DF0019` when a definition has `agent` set but is not declared `jsonSerializable: true`. diff --git a/docs/errors/DF0020.md b/docs/errors/DF0020.md new file mode 100644 index 0000000..36395f2 --- /dev/null +++ b/docs/errors/DF0020.md @@ -0,0 +1,56 @@ +--- +outline: deep +--- + +# DF0020: Non-JSON Value in JSON-Serializable RPC + +## Message + +> RPC function "`{name}`" declares `jsonSerializable: true` but the value at "`{path}`" is a `{type}`. + +## Cause + +The function is declared `jsonSerializable: true`, which means its args and return value are encoded with strict `JSON.stringify` (both on the wire and in build dumps). The strict serializer rejects any value that JSON cannot round-trip losslessly: + +- `Map`, `Set`, `WeakMap`, `WeakSet` +- `Date` (silently coerced to ISO string by JSON) +- `BigInt` +- circular references +- non-plain class instances +- `undefined` leaves +- `Symbol` +- `Function` + +When the strict serializer encounters one of these, it throws synchronously at the offending call rather than producing a corrupt payload. + +## Example + +```ts +defineRpcFunction({ + name: 'my-plugin:graph', + jsonSerializable: true, + handler: () => ({ + nodes: new Map([['a', 1]]), // ← throws DF0020 with type=Map, path="nodes" + }), +}) +``` + +## Fix + +Either drop `jsonSerializable: true` so the function uses `structured-clone-es` (round-trips `Map`, `Set`, etc.): + +```ts +defineRpcFunction({ + name: 'my-plugin:graph', + // jsonSerializable: false (default) — Map/Set survive the wire and the dump + handler: () => ({ + nodes: new Map([['a', 1]]), + }), +}) +``` + +Or convert the payload to a JSON-safe shape (e.g. an array of entries, an ISO string, a plain object) before returning. Note: removing `jsonSerializable: true` also disables `agent` exposure; if you need MCP, you must use a JSON-safe shape. + +## Source + +- [`packages/devframe/src/rpc/serialization.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/serialization.ts) — the strict JSON serializer throws `DF0020` (with the offending path and runtime type) when a `jsonSerializable: true` payload contains a non-JSON value. diff --git a/docs/errors/DF0021.md b/docs/errors/DF0021.md new file mode 100644 index 0000000..47159f9 --- /dev/null +++ b/docs/errors/DF0021.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0021: RPC Function Already Registered + +## Message + +> RPC function "`{name}`" is already registered + +## Cause + +`ctx.rpc.register()` was called twice with the same `name`. RPC names must be unique within a devtool. + +## Fix + +Either give the second registration a distinct name, or pass `force: true` to overwrite the previous one (e.g. during HMR-driven re-registration). + +```ts +ctx.rpc.register(defineRpcFunction({ name: 'my-plugin:fn', handler: () => 1 }), true /* force */) +``` + +## Source + +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.register()` throws `DF0021` when an RPC name is already registered and `force` is not set. diff --git a/docs/errors/DF0022.md b/docs/errors/DF0022.md new file mode 100644 index 0000000..5941f87 --- /dev/null +++ b/docs/errors/DF0022.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0022: RPC Function Not Registered (Update) + +## Message + +> RPC function "`{name}`" is not registered. Use register() to add new functions. + +## Cause + +`ctx.rpc.update()` was called for a function that was never registered. `update()` is for replacing an existing definition. + +## Fix + +Call `ctx.rpc.register()` first, or pass `force: true` to `update()` to register-or-replace in one call. + +## Source + +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — `RpcFunctionsCollectorBase.update()` throws `DF0022` when the named function was never registered. diff --git a/docs/errors/DF0023.md b/docs/errors/DF0023.md new file mode 100644 index 0000000..5937e06 --- /dev/null +++ b/docs/errors/DF0023.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0023: RPC Function Not Registered (Get) + +## Message + +> RPC function "`{name}`" is not registered + +## Cause + +A consumer asked for the schema or handler of a function that has never been registered with `ctx.rpc.register()`. + +## Fix + +Confirm the function name matches a registration. RPC names are namespaced — typos in the prefix are a common cause. + +## Source + +- [`packages/devframe/src/rpc/collector.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/collector.ts) — collector `get()`/lookup paths throw `DF0023` when consumers ask for a function that has not been registered. diff --git a/docs/errors/DF0024.md b/docs/errors/DF0024.md new file mode 100644 index 0000000..21c170f --- /dev/null +++ b/docs/errors/DF0024.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF0024: Missing RPC Handler + +## Message + +> Either handler or setup function must be provided for RPC function "`{name}`" + +## Cause + +The RPC definition has neither a `handler` nor a `setup` returning `{ handler }`. devframe has nothing to invoke when the function is called. + +## Fix + +Add either `handler: ...` directly on the definition, or `setup: ctx => ({ handler: ... })` if the handler depends on context. + +## Source + +- [`packages/devframe/src/rpc/handler.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/handler.ts) — invocation throws `DF0024` when neither `handler` nor a `setup` returning `{ handler }` is provided. +- [`packages/devframe/src/rpc/dumps.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/dumps.ts) — dump generation also requires a handler and throws `DF0024` if the definition is incomplete. diff --git a/docs/errors/DF0025.md b/docs/errors/DF0025.md new file mode 100644 index 0000000..8d7c02f --- /dev/null +++ b/docs/errors/DF0025.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0025: Function Not in Dump Store + +## Message + +> Function "`{name}`" not found in dump store + +## Cause + +A static-mode client called an RPC function that was not baked into the build dump. This usually means the function was added after the dump was generated, or its name changed between build and runtime. + +## Fix + +Re-run `createBuild` to regenerate the dump, or check that the call site uses the same name registered on the server. + +## Source + +- [`packages/devframe/src/rpc/dumps.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/dumps.ts) — the static-mode dump resolver throws `DF0025` when a client calls a function name that is not present in the baked dump store. diff --git a/docs/errors/DF0026.md b/docs/errors/DF0026.md new file mode 100644 index 0000000..0859f4e --- /dev/null +++ b/docs/errors/DF0026.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# DF0026: No Dump Match + +## Message + +> No dump match for "`{name}`" with args: `{args}` + +## Cause + +A static-mode client called an RPC function with arguments that don't match any pre-computed record, and no `fallback` was set on the dump. + +## Fix + +Either widen the function's `dump.inputs` to cover the requested arguments, or provide `dump.fallback` so unmatched calls resolve to a default value instead of throwing. + +```ts +defineRpcFunction({ + name: 'my-plugin:get', + dump: { + inputs: [['known-id']], + fallback: null, + }, +}) +``` + +## Source + +- [`packages/devframe/src/rpc/dumps.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/dumps.ts) — the static-mode dump resolver throws `DF0026` when none of the pre-computed inputs matches the call's args and no `fallback` was configured. diff --git a/docs/errors/DF0027.md b/docs/errors/DF0027.md new file mode 100644 index 0000000..099ed35 --- /dev/null +++ b/docs/errors/DF0027.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0027: Invalid Dump Configuration + +## Message + +> Function "`{name}`" with type "`{type}`" cannot have dump configuration. Only "static" and "query" types support dumps. + +## Cause + +A `dump` field was attached to an `'action'` or `'event'` function. These types perform side effects rather than returning queryable data — there is nothing meaningful to pre-compute. + +## Fix + +Drop the `dump` field, or change the function `type` to `'static'` / `'query'` if pre-computation is appropriate. + +## Source + +- [`packages/devframe/src/rpc/validation.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/validation.ts) — definition validation throws `DF0027` when a `dump` field is attached to an `'action'` or `'event'` function. diff --git a/docs/errors/DF0028.md b/docs/errors/DF0028.md new file mode 100644 index 0000000..a0f840e --- /dev/null +++ b/docs/errors/DF0028.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0028: Snapshot Type Mismatch + +## Message + +> Function "`{name}`" with type "`{type}`" cannot use `snapshot: true`. Only "query" functions support this sugar; "static" functions have equivalent default behavior already. + +## Cause + +`snapshot: true` is sugar for "query in dev, single baked snapshot in build". It is only meaningful on `'query'` functions — `'static'` already has equivalent default behavior, and `'action'` / `'event'` have nothing to snapshot. + +## Fix + +Remove `snapshot: true`, or change the function `type` to `'query'`. + +## Source + +- [`packages/devframe/src/rpc/validation.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/rpc/validation.ts) — definition validation throws `DF0028` when `snapshot: true` is set on a function whose type is not `'query'`. diff --git a/docs/errors/DF0029.md b/docs/errors/DF0029.md new file mode 100644 index 0000000..a4111b4 --- /dev/null +++ b/docs/errors/DF0029.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0029: Stream Buffer Overflow + +## Message + +> Stream "`{channel}#{id}`" dropped `{dropped}` chunk(s) after exceeding the client high-water mark. + +## Cause + +A streaming subscriber's queue grew past its `highWaterMark` because the consumer is slower than the producer. The oldest chunks were dropped to keep memory bounded. + +This is a soft warning — the stream keeps running and remaining chunks still flow. + +## Fix + +- Raise `highWaterMark` on `rpc.streaming.subscribe(channel, id, { highWaterMark })` if the consumer can occasionally catch up. +- Slow the producer so it doesn't outpace the wire (e.g. throttle, debounce, or batch chunks server-side). +- Switch to `sharedState` if you only need the latest value rather than every intermediate chunk. + +## Source + +- [`packages/devframe/src/client/rpc-streaming.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/client/rpc-streaming.ts) — the client subscription queue logs `DF0029` (with the dropped chunk count) when buffered chunks exceed `highWaterMark`. diff --git a/docs/errors/DF0030.md b/docs/errors/DF0030.md new file mode 100644 index 0000000..b7d0d28 --- /dev/null +++ b/docs/errors/DF0030.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# DF0030: Unknown Stream ID + +## Message + +> Stream "`{channel}#{id}`" is unknown — no producer has called `channel.start({ id: "{id}" })`. + +## Cause + +A client subscribed to a stream id that the server-side channel doesn't know about. Either the producer never started a stream with that id, the producer already ended it and `replayWindow` is `0`, or the client passed the wrong id. + +## Fix + +- Make sure the action that returns the stream id runs **before** the client subscribes — typically by awaiting `rpc.call('your-action')` and using the returned id. +- Bump `replayWindow` on `ctx.rpc.streaming.create(name, { replayWindow })` if you need clients to resume after the producer has finished but kept the buffer warm. +- Check the id is propagated correctly across boundaries (action return value → component prop → subscribe call). + +## Source + +- [`packages/devframe/src/node/rpc-streaming.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/rpc-streaming.ts) — the streaming subscribe/unsubscribe paths log `DF0030` when a client references an `id` that no producer has started (and no replay buffer covers). diff --git a/docs/errors/DF0031.md b/docs/errors/DF0031.md new file mode 100644 index 0000000..7a80000 --- /dev/null +++ b/docs/errors/DF0031.md @@ -0,0 +1,36 @@ +--- +outline: deep +--- + +# DF0031: Write to Closed Stream + +## Message + +> Cannot write to closed stream "`{channel}#{id}`". + +## Cause + +`stream.write(chunk)` was called after the stream was closed via `stream.close()` / `stream.error()` or after the consumer cancelled (which aborts `stream.signal`). + +## Fix + +Producers should poll `stream.signal.aborted` and exit cleanly: + +```ts +const stream = channel.start({ id }) +try { + for (const chunk of source) { + if (stream.signal.aborted) + return + stream.write(chunk) + } + stream.close() +} +catch (err) { + stream.error(err) +} +``` + +## Source + +- [`packages/devframe/src/utils/streaming-channel.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/utils/streaming-channel.ts) — `stream.write()` throws `DF0031` when called after the stream has been closed, errored, or aborted by the consumer. diff --git a/docs/errors/DF0032.md b/docs/errors/DF0032.md new file mode 100644 index 0000000..109fcca --- /dev/null +++ b/docs/errors/DF0032.md @@ -0,0 +1,22 @@ +--- +outline: deep +--- + +# DF0032: Streaming Channel Already Registered + +## Message + +> Streaming channel "`{channel}`" is already registered. + +## Cause + +Two calls to `ctx.rpc.streaming.create(name, ...)` used the same channel name. Each name owns a wire namespace and must be unique within a context. + +## Fix + +- Reuse the existing channel handle rather than creating a new one with the same name. +- If two devtools want isolated streams, give each a distinct namespaced name (`my-devtool:chat-stream`, `other-devtool:logs-stream`). + +## Source + +- [`packages/devframe/src/node/rpc-streaming.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/node/rpc-streaming.ts) — `ctx.rpc.streaming.create()` throws `DF0032` when the requested channel name is already registered on the context. diff --git a/docs/errors/DF0033.md b/docs/errors/DF0033.md new file mode 100644 index 0000000..6c67a04 --- /dev/null +++ b/docs/errors/DF0033.md @@ -0,0 +1,29 @@ +--- +outline: deep +--- + +# DF0033: Dev RPC Bridge Failed to Start + +## Message + +> Failed to start dev RPC bridge for "`{id}`": `{reason}` + +## Cause + +`createVitePlugin({ devMiddleware })` could not bring up the bridge dev server that pairs a host-served SPA (Vite, Nuxt, Astro, etc.) with devframe's RPC backend. Common reasons: + +- The preferred port is in use and no fallback range was configured. +- Calling `def.setup(ctx)` threw — the devframe's own setup logic surfaced an error. +- A required peer (e.g. `get-port-please` or `h3`) is missing or mismatched. + +This is a soft warning — the surrounding Vite dev server keeps running, but the host-served SPA will fail its `__connection.json` lookup until the bridge starts. + +## Fix + +- Pin a port via `cli.port` / `cli.portRange` on the devframe definition, or via `devMiddleware.port` on `createVitePlugin`. +- Inspect the `reason` (or the attached `cause`) for the underlying error — fix the setup function or free the port. +- For Nuxt: pass `devMiddleware: { port: }` to the `@devframes/nuxt` module. + +## Source + +- [`packages/devframe/src/adapters/vite.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/adapters/vite.ts) — `createVitePlugin({ devMiddleware })` logs `DF0033` when port resolution or `createDevServer` throws during `configureServer`. diff --git a/docs/errors/index.md b/docs/errors/index.md new file mode 100644 index 0000000..873afc6 --- /dev/null +++ b/docs/errors/index.md @@ -0,0 +1,44 @@ +--- +outline: deep +--- + +# Error Reference + +Devframe uses structured diagnostics to surface actionable warnings and errors at runtime. Each diagnostic has a unique error code, a human-readable message, and a link back to this documentation. + +## How error codes work + +- Codes follow the pattern **`DF` + 4-digit number** (e.g., `DF0001`). +- Every error page includes the cause, recommended fix, and a reference to the source file that emits it. +- The diagnostics system is powered by [`logs-sdk`](https://github.com/vercel-labs/logs-sdk), which provides structured logging with docs URLs, ANSI-formatted console output, and level-based filtering. + +## Devframe (DF) + +Emitted by `devframe` — framework-neutral host / shared-state / auth surface. + +| Code | Level | Title | +|------|-------|-------| +| [DF0006](./DF0006) | error | RPC Function Not Registered | +| [DF0007](./DF0007) | error | AsyncLocalStorage Not Set | +| [DF0008](./DF0008) | error | View distDir Not Found | +| [DF0012](./DF0012) | warn | Storage Parse Failed | +| [DF0013](./DF0013) | error | Shared State Not Found | +| [DF0014](./DF0014) | error | Invalid Agent Field | +| [DF0015](./DF0015) | error | Agent Tool Already Registered | +| [DF0016](./DF0016) | error | Agent Resource Already Registered | +| [DF0017](./DF0017) | error | MCP Server Start Failure | +| [DF0019](./DF0019) | error | Agent Requires JSON-Serializable RPC | +| [DF0020](./DF0020) | error | Non-JSON Value in JSON-Serializable RPC | +| [DF0021](./DF0021) | error | RPC Function Already Registered | +| [DF0022](./DF0022) | error | RPC Function Not Registered (Update) | +| [DF0023](./DF0023) | error | RPC Function Not Registered (Get) | +| [DF0024](./DF0024) | error | Missing RPC Handler | +| [DF0025](./DF0025) | error | Function Not in Dump Store | +| [DF0026](./DF0026) | error | No Dump Match | +| [DF0027](./DF0027) | error | Invalid Dump Configuration | +| [DF0028](./DF0028) | error | Snapshot Type Mismatch | +| [DF0029](./DF0029) | warn | Stream Buffer Overflow | +| [DF0030](./DF0030) | error | Unknown Stream ID | +| [DF0031](./DF0031) | error | Write to Closed Stream | +| [DF0032](./DF0032) | error | Streaming Channel Already Registered | +| [DF0033](./DF0033) | warn | Dev RPC Bridge Failed to Start | diff --git a/docs/guide/adapters.md b/docs/guide/adapters.md new file mode 100644 index 0000000..513420d --- /dev/null +++ b/docs/guide/adapters.md @@ -0,0 +1,310 @@ +--- +outline: deep +--- + +# Adapters + +An adapter takes a `DevframeDefinition` and deploys it into a specific runtime — a standalone CLI, a Vite plugin, a static snapshot, an SPA, a Kit plugin, an embedded host, or an MCP server. Each adapter ships at its own entry point (`devframe/adapters/`); the bundler pulls in only the ones you use. + +Every adapter factory has the shape `createXxx(devframeDef, options?)`. + +## Comparison + +| Adapter | Entry | Factory | Best for | +|---------|-------|---------|----------| +| [`cli`](#cli) | `devframe/adapters/cli` | `createCli(def, options?)` | Standalone tools run via `node ./my-tool.js` | +| [`dev`](#dev) | `devframe/adapters/dev` | `createDevServer(def, options?)` | Run the dev server programmatically — drive it from any CLI framework | +| [`vite`](#vite) | `devframe/adapters/vite` | `createVitePlugin(def, options?)` | Mount a tool's UI inside an existing Vite dev server | +| [`build`](#build) | `devframe/adapters/build` | `createBuild(def, options?)` | Offline reports, CI artifacts, deployable SPA snapshots | +| [`kit`](#kit) | `@vitejs/devtools-kit/node` | `createPluginFromDevframe(def, options?)` | Integrating into Vite DevTools Kit | +| [`embedded`](#embedded) | `devframe/adapters/embedded` | `createEmbedded(def, { ctx })` | Runtime registration into an already-running host | +| [`mcp`](#mcp) | `devframe/adapters/mcp` | `createMcpServer(def, options?)` | Exposing a devframe to coding agents | + +## CLI + +The CLI adapter wraps a `DevframeDefinition` in a `cac`-powered command-line interface. From one entry it spins up an `h3` dev server with WebSocket RPC, builds static snapshots, builds SPA bundles, or starts an MCP server. + +```ts +import { defineDevframe } from 'devframe' +import { createCli } from 'devframe/adapters/cli' + +const devframe = defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + cli: { distDir: './client/dist' }, + setup(ctx) { /* register docks, RPC, etc. */ }, +}) + +await createCli(devframe).parse() +``` + +Running the resulting binary: + +```sh +my-devframe # dev server at http://localhost:9999/ +my-devframe --port 8080 +my-devframe build --out-dir dist-static +my-devframe build --out-dir dist-static --base /devtools/ +my-devframe mcp # stdio MCP server (experimental) +``` + +Standalone CLI serves the SPA at `/` by default. The `/__devtools/` prefix is for *hosted* adapters where devframe mounts alongside an existing app — see [Mount paths](#mount-paths). + +### Options + +`createCli(def, options?)` accepts: + +| Option | Default | Description | +|--------|---------|-------------| +| `defaultPort` | `9999` (or `def.cli?.port`) | Port used by the dev command when `--port` isn't provided. | +| `configureCli` | — | `(cli: CAC) => void` — final hook to add commands/flags at the assembly stage, after the definition's `cli.configure` runs. | +| `onReady` | — | `(info: { origin, port, app }) => void \| Promise` — called once the dev server is listening. Use this to print your own startup banner. | + +`createCli` returns a `CliHandle`: + +```ts +interface CliHandle { + cli: CAC // raw cac instance — mutate before calling parse() + parse: (argv?: string[]) => Promise +} +``` + +The `cli` property lets the caller add ad-hoc commands and flags right before `parse()` when a `configureCli` callback is inconvenient. + +### Definition-level `cli` fields + +```ts +defineDevframe({ + id: 'my-devframe', + cli: { + command: 'my-devframe', // binary name; default: the id + distDir: './client/dist', // required for dev/build/spa + port: 7777, // preferred port + portRange: [7777, 9000], // passed through to get-port-please + random: false, // passed through to get-port-please + host: '127.0.0.1', // default host; --host overrides + open: true, // auto-open the browser on dev start + auth: false, // skip the trust handshake (single-user localhost) + configure(cli) { // contribute capability flags/commands + cli.option('--config ', 'Custom config file') + .option('--no-files', 'Skip file matching') + }, + }, + setup(ctx, { flags }) { + // `flags` is the parsed cac flag bag — includes both devframe's + // built-ins (`--port`, `--host`, `--open`) and anything declared in + // `cli.configure` or `configureCli`. + }, +}) +``` + +`distDir` is the only required field; everything else has sensible defaults. The `configure` hook runs *before* the `configureCli` option passed to `createCli`, so the final tool author always has the last word on flags. + +### Headless logging + +Devframe leaves startup output to the application. Wire `onReady` to print your own banner: + +```ts +await createCli(devframe, { + onReady({ origin }) { + console.log(`ESLint Config Inspector ready at ${origin}`) + }, +}).parse() +``` + +Structured diagnostics (via `logs-sdk`) continue to surface through their normal reporters. + +### Use your own CLI framework + +To integrate devframe into an existing commander / yargs program — or to expose a different command structure than `createCli`'s `dev` / `build` / `mcp` triplet — drop down to the peer factories. Same `DevframeDefinition`, different shell: + +| Building block | Entry | Purpose | +|----------------|-------|---------| +| [`createDevServer(def, opts?)`](#dev) | `devframe/adapters/dev` | h3 + WebSocket RPC + SPA mount | +| [`createBuild(def, opts?)`](#build) | `devframe/adapters/build` | Static deploy | +| [`createMcpServer(def, opts?)`](#mcp) | `devframe/adapters/mcp` | stdio MCP server | +| `parseCliFlags(schema, raw)` | `devframe/adapters/cli` | Validate a flag bag against a `CliFlagsSchema` | + +See the [Standalone CLI guide](./standalone-cli#use-your-own-cli-framework) for a worked commander example. + +## Dev + +The `dev` adapter is the building block `createCli` uses internally — h3 + WebSocket RPC + the author's SPA mounted at the resolved base path. Reach for it directly to mount the dev server inside an existing CLI program (commander, yargs, hand-rolled CAC) or to attach custom middleware to the underlying h3 app. + +```ts +import { createDevServer } from 'devframe/adapters/dev' +import devframe from './devframe' + +const handle = await createDevServer(devframe, { + port: 7777, + onReady: ({ origin }) => console.log(`Ready at ${origin}`), +}) + +// graceful shutdown — SIGINT, hot reload, test teardown +process.on('SIGINT', () => handle.close().then(() => process.exit(0))) +``` + +`createDevServer` returns the underlying `StartedServer` (origin, port, h3 app, WS server, RPC group, `close()`) so callers can integrate it into their own process lifecycle. + +| Option | Default | Description | +|--------|---------|-------------| +| `host` | `def.cli?.host ?? 'localhost'` | Bind host. | +| `port` | resolved via `resolveDevServerPort` | Port to listen on. | +| `flags` | `{}` | Parsed flag bag forwarded to `setup(ctx, { flags })`. | +| `distDir` | `def.cli?.distDir` | Required — throws when neither is set. | +| `basePath` | `resolveBasePath(def, 'standalone')` | Mount path override. | +| `app` | fresh h3 app | Pre-configured h3 app to mount onto (custom middleware, auth, extra static assets). | +| `openBrowser` | resolves from `flags.open` / `def.cli?.open` | Explicit on/off override. `false` disables; a string opens that relative path. | +| `onReady` | — | Callback when the WS server is bound. | + +### Port resolution + +`resolveDevServerPort(def, opts?)` resolves a port up-front (to print or log it) before the server starts: + +```ts +import { resolveDevServerPort } from 'devframe/adapters/dev' + +const port = await resolveDevServerPort(devframe, { host: '127.0.0.1' }) +// honors def.cli?.port / portRange / random +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `host` | `def.cli?.host ?? 'localhost'` | Bind host (passed to `get-port-please` for in-use detection). | +| `defaultPort` | `def.cli?.port ?? 9999` | Override the preferred port. | + +## Mount paths + +A devframe's SPA basePath depends on which adapter is running it: + +| Adapter kind | Default basePath | Reason | +|--------------|------------------|--------| +| `cli`, `spa`, `build` (standalone) | `/` | The devframe owns the origin. | +| `vite`, `kit`, `embedded` (hosted) | `/__/` | The devframe shares the origin with a host app and namespaces itself. | + +Override either side explicitly with `DevframeDefinition.basePath`: + +```ts +defineDevframe({ + id: 'my-devframe', + basePath: '/devframes/', // force this base regardless of adapter + setup(ctx) { /* … */ }, +}) +``` + +SPA authors should build with relative asset paths (`vite.base: './'`); the client resolves its connection descriptor relative to the page at runtime. See [Client](./client#runtime-basepath-discovery) for the discovery rules. + +## Vite + +A thin Vite plugin that mounts a devframe's SPA into an existing Vite dev server as a *hosted* adapter — the mount path defaults to `/__/` to namespace away from the app. The plugin mounts the SPA only; for RPC, use `kit` or `cli`. + +```ts +import { createVitePlugin } from 'devframe/adapters/vite' +import { defineConfig } from 'vite' +import devframe from './devframe' + +export default defineConfig({ + plugins: [createVitePlugin(devframe)], +}) +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `base` | `def.basePath ?? '/__/'` | Mount path inside the Vite dev server. | + +Use this adapter when a devframe's UI is purely static and you want to surface it during Vite `serve` without shipping a separate dev server. Set `DevframeDefinition.basePath` on the definition for a custom path that stays consistent across adapters. + +## Build + +Produces a self-contained static deploy of a devframe: + +1. Copies the author's SPA dist (`cli.distDir` or `options.distDir`) into ``. +2. Runs `setup(ctx)` with `mode: 'build'`. +3. Collects RPC dumps for every `'static'` function and any `'query'` function with `dump.inputs` / `snapshot: true`. +4. Writes `/__connection.json` (`{ backend: 'static' }`) and sharded dump files under `/__rpc-dump/` — both at the SPA root so the deployed client discovers them via relative paths from `document.baseURI`. +5. When `def.spa` is set, also writes `/spa-loader.json` describing how the SPA hydrates its data. + +```ts +import { createBuild } from 'devframe/adapters/build' +import devframe from './devframe' + +await createBuild(devframe, { + outDir: 'dist-static', + base: '/', +}) +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `outDir` | `dist-static` | Output directory. Cleared on each build. | +| `base` | `/` | Absolute URL base the output is served from. | +| `distDir` | `def.cli?.distDir` | Override the SPA dist directory. | + +The resulting directory hosts on any static web server (`serve`, nginx, GitHub Pages, …). The client auto-detects `static` mode by resolving `./__connection.json` against `document.baseURI` and runs in read-only form. + +`createBuild` copies the SPA verbatim, so deploying under a custom URL base just means building the SPA with relative asset paths (`vite.base: './'`) — the client discovers the effective base at runtime. + +When `def.spa` is set on the definition, `createBuild` also writes `spa-loader.json` next to `index.html` describing how the deployed SPA sources its data: + +- `'none'` — use the baked RPC dump only (read-only static view). +- `'query'` — hydrate from URL search params. +- `'upload'` — accept a drag-and-drop file. + +Deployed SPAs that use `setupBrowser` ship their own client entry that registers the handlers. + +## Kit + +Wraps a `DevframeDefinition` so Vite DevTools Kit's plugin-scan picks it up. The factory lives in `@vitejs/devtools-kit/node` — kit owns docking and process management while devframe stays portable. + +```ts +import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' +import devframe from './devframe' + +export default function myVitePlugin() { + return createPluginFromDevframe(devframe) +} +``` + +The returned object has the shape `{ name, devtools: { setup, capabilities } }`. Use this adapter when your devframe should live inside the Vite DevTools dock alongside other integrations. Kit synthesises an iframe dock entry from the definition's `id` / `name` / `icon` / `basePath`; for richer kit-specific behaviour (extra terminals, commands, dock overrides) pass `options.setup`. See the [DevTools Kit → DevTools Plugin](https://devtools.vite.dev/kit/devtools-plugin) page for the Vite-specific guide. + +| Option | Default | Description | +|--------|---------|-------------| +| `name` | `devframe:` | Override the Vite plugin name. | +| `base` | `def.basePath ?? /.${id}/` | Mount path override. | +| `dock` | `{}` | Overrides for the synthesized iframe dock entry (category, icon, when). | +| `setup` | — | Additional kit-only setup hook; receives the kit-augmented context. | + +## Embedded + +Register a devframe into an already-running context at runtime. Mirrors Kit's internal plugin-scan, but for callers that need dynamic, post-startup registration. The host decides the mount path; `embedded` is a hosted adapter and inherits the `/__/` default when one is needed. + +```ts +import { createEmbedded } from 'devframe/adapters/embedded' +import devframe from './devframe' + +await createEmbedded(devframe, { ctx: existingCtx }) +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `ctx` | ✓ | Target `DevToolsNodeContext` the devframe is registered into. | + +Useful when a host loads devframes based on runtime conditions (feature flags, user opt-in, dynamic discovery) rather than static config. + +## MCP + +> [!WARNING] Experimental +> The agent-native surface is experimental and may change without a major version bump. + +Translates a devframe's agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server so coding agents (Claude Desktop, Cursor, Zed, Claude Code) can call flagged RPCs and read exposed resources. + +```ts +import { createMcpServer } from 'devframe/adapters/mcp' +import devframe from './devframe' + +await createMcpServer(devframe, { transport: 'stdio' }) +``` + +`@modelcontextprotocol/sdk` is a peer dependency — install it when shipping MCP support. The current transport is `stdio`. + +See the [Agent-Native](./agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md new file mode 100644 index 0000000..3049c7f --- /dev/null +++ b/docs/guide/agent-native.md @@ -0,0 +1,131 @@ +--- +outline: deep +--- + +# Agent-Native Devframe + +::: warning Experimental +The agent-native surface (`agent` field on `defineRpcFunction`, `DevToolsAgentHost`, and the `devframe/adapters/mcp` adapter) is experimental and may change without a major version bump until it stabilizes. +::: + +Devframe can expose the same surface a browser UI consumes — RPC functions, resources, and shared state — to coding agents (Claude Desktop / Cursor / Zed / Claude Code, or any MCP-speaking client). Agent exposure is opt-in per function; functions stay private by default. + +## How it works + +Three building blocks: + +1. **An `agent` field on `defineRpcFunction`.** Add `agent: { description, ... }` to opt a function in. Functions without the field stay private. +2. **`ctx.agent`** — a host exposed on `DevToolsNodeContext`. Plugins register tools that aren't backed by an RPC, and expose readable resources (e.g. a Markdown build summary). +3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, currently over `stdio`. + +## Exposing an RPC function + +```ts +import { defineRpcFunction } from 'devframe' + +export const getSessionSummary = defineRpcFunction({ + name: 'rolldown-get-session-summary', + type: 'query', + args: [v.object({ sessionId: v.string() })], + returns: v.object({ durationMs: v.number(), chunkCount: v.number() }), + agent: { + description: 'Summarize a Rolldown build session. Safe to call freely.', + title: 'Build summary', + // safety inferred from `type: 'query'` → 'read' + }, + setup: ctx => ({ + handler: async ({ sessionId }) => { + // ... + }, + }), +}) +``` + +Agent tools take a single object input. The MCP adapter synthesises `arg0`, `arg1`, … from positional args (`args: [A, B]`); a single object schema (`args: [v.object({ ... })]`) reads better at the agent boundary because property names are self-describing. + +## Registering a plugin tool + +For tools without a matching RPC — say, an on-demand narrative summary — register them directly: + +```ts +export default defineDevframe({ + id: 'my-plugin', + setup(ctx) { + ctx.agent.registerTool({ + id: 'my-plugin:summarize', + description: 'Plain-text summary of the current build state.', + safety: 'read', + handler: async () => ({ + markdown: buildSummary(), + }), + }) + }, +}) +``` + +## Registering a resource + +Resources surface readable snapshots of state, identified by URI: + +```ts +ctx.agent.registerResource({ + id: 'current-session', + name: 'Current Rolldown session', + description: 'Markdown snapshot of the active build session.', + mimeType: 'text/markdown', + read: () => ({ text: renderMarkdown(currentSession) }), +}) +``` + +Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out. + +## Starting the MCP server + +The simplest path is the CLI: + +```sh +# Run your devtool with an MCP stdio server attached. +devframe mcp +``` + +Programmatic equivalent: + +```ts +import { defineDevframe } from 'devframe' +import { createMcpServer } from 'devframe/adapters/mcp' + +const devframe = defineDevframe({ /* … */ }) + +await createMcpServer(devframe, { transport: 'stdio' }) +``` + +`@modelcontextprotocol/sdk` is a peer dependency — add it to your package when you want to ship an MCP-enabled devframe. + +## Connecting Claude Desktop + +Add an entry to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "my-devframe": { + "command": "pnpm", + "args": ["--filter", "my-devframe", "exec", "devframe", "mcp"] + } + } +} +``` + +Restart Claude Desktop. The tools you flagged with `agent: { ... }` (plus any `registerTool` calls) show up in the MCP tool drawer. Resources are reachable as `devframe://resource/` and `devframe://state/` URIs. + +## Safety model + +- **Opt-in exposure.** Functions opt in via the `agent` field; everything else stays private. +- **`safety`** — one of `'read'`, `'action'`, `'destructive'`. Inferred from the RPC `type` (`static`/`query` → `read`, `action`/`event` → `action`), with explicit override available. +- The MCP adapter maps `safety` to tool annotations (`readOnlyHint`, `destructiveHint`). MCP clients use these to decide whether to prompt for confirmation before calling. + +## CLI + +| Command | Description | +|---------|-------------| +| `devframe mcp` | Start an MCP server on `stdio`. | diff --git a/docs/guide/client.md b/docs/guide/client.md new file mode 100644 index 0000000..0af4168 --- /dev/null +++ b/docs/guide/client.md @@ -0,0 +1,208 @@ +--- +outline: deep +--- + +# Client + +The browser-side client is how a dock iframe, remote-hosted page, or standalone SPA talks to the Devframe server. It provides type-safe RPC calls, access to shared state, and (in dev mode) a trust handshake against the local dev server. + +## Connecting + +`devframe/client` exports `connectDevframe` (an alias of `getDevToolsRpcClient`) — use either name: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() + +const modules = await rpc.call('my-devframe:get-modules', { limit: 10 }) +``` + +`connectDevframe` auto-detects the backend via `__devtools/__connection.json`, with a sequence of base URLs as fallback. No arguments are needed when the client is hosted from the default mount path. + +### Runtime basePath discovery + +Devframe SPAs are base-agnostic — the same artifact can be served at `/`, `/__/`, or any custom subpath without rebuilding. `connectDevframe` resolves `__connection.json` at runtime by reading `document.baseURI` and the executing script's URL. + +For SPA authors, that means: + +- Build with relative asset paths — Vite `base: './'`, Nuxt `vite.base: './'` + `app.baseURL: './'`. +- Leave the mount path out of the HTML. The server serves files at *some* base; the client figures out which. +- Skip the `baseURL` option on `connectDevframe` unless you're connecting across origins or to a non-colocated devframe server. + +That's how `createBuild` deploys SPA output verbatim under any URL — no build-time HTML rewriting needed. + +### Options + +```ts +await connectDevframe({ + baseURL: './', // string or string[] fallback list — see notes below + authToken: 'user-provided-token', + cacheOptions: true, // enable response caching + wsOptions: { /* … */ }, + rpcOptions: { /* birpc options */ }, +}) +``` + +| Option | Description | +|--------|-------------| +| `baseURL` | Mount path to probe for `__connection.json`. Accepts an array for fallback. Default: `'./'` — resolved relative to `document.baseURI` so the SPA finds its meta wherever it was deployed. Pass an explicit absolute path (e.g. `'/__devtools/'`) when calling from outside the SPA — say, an embedded webcomponent injected into a host app. | +| `authToken` | Override the auth token. Defaults to a locally-persisted human-readable id. | +| `cacheOptions` | `true` to enable caching with defaults, or an options object. | +| `wsOptions` | Forwarded to the WebSocket transport (reconnect, heartbeat, etc.). | +| `rpcOptions` | Forwarded to `birpc`. | +| `connectionMeta` | Pre-known descriptor that skips the `__connection.json` fetch. | + +## Modes + +The client runs in one of two modes depending on the backend advertised in `__devtools/__connection.json`: + +| Backend | When | Capabilities | +|---------|------|--------------| +| `websocket` | Dev mode (`createCli`, Kit) | Full read/write, broadcasts, shared-state mutation. Requires auth. | +| `static` | Build / SPA output | Read-only — all calls resolve against the baked RPC dump. | + +The client picks a mode automatically from the backend field. Mode-specific code paths like `broadcast` are scoped to `websocket`. + +## Trust & auth (WebSocket mode) + +Dev-mode connections require trust before the server accepts calls. The client handles this automatically: on first connect it submits the locally-stored auth token, and `ensureTrusted()` resolves once the server accepts. + +```ts +const rpc = await connectDevframe() + +// Blocks until the server trusts this client (default timeout 60s) +const trusted = await rpc.ensureTrusted() + +if (!trusted) { + console.warn('Auth denied') +} +``` + +### Replacing the token + +For tokens supplied from a different source (e.g. copy-pasted from CLI output), swap one in without reloading: + +```ts +const ok = await rpc.requestTrustWithToken('another-token') +``` + +### Broadcast-channel sync + +`connectDevframe` listens on `BroadcastChannel('vite-devtools-auth')` for `auth-update` messages. When an auth page in another tab announces a new token, every open client requests trust with it automatically — no reload required. + +## Calling functions + +```ts +const rpc = await connectDevframe() + +// Standard call — awaits a response or throws. +const modules = await rpc.call('my-devframe:get-modules', { limit: 10 }) + +// Optional — returns undefined when no handler responds (useful while HMR is restarting). +const maybe = await rpc.callOptional('my-devframe:get-modules', { limit: 10 }) + +// Event — fire-and-forget, no response expected. +rpc.callEvent('my-devframe:notify', { message: 'hello' }) +``` + +TypeScript types flow through from the server's `defineRpcFunction` definitions, so argument and return shapes are known at the call site. + +## Registering client functions + +The client can register functions that the server calls via `ctx.rpc.broadcast`: + +```ts +import { defineRpcFunction } from 'devframe' + +rpc.client.register(defineRpcFunction({ + name: 'my-devframe:on-file-changed', + type: 'event', + setup: () => ({ + handler: async ({ file }: { file: string }) => { + console.log('server says:', file, 'changed') + }, + }), +})) +``` + +That's how the server pushes live updates into the UI — file-watcher events, shared-state sync, and so on. + +## Shared state + +```ts +const state = await rpc.sharedState.get('my-devframe:state') + +console.log(state.value()) + +state.mutate((draft) => { + draft.count += 1 +}) + +state.on('updated', (next) => { + console.log('new state', next) +}) +``` + +Client-side mutations round-trip through the server before reappearing locally. See [Shared State](./shared-state) for the full API. + +## Caching + +Set `cacheOptions: true` (or an options object) when constructing the client: + +```ts +const rpc = await connectDevframe({ cacheOptions: true }) +``` + +With caching on, `query` / `static` function responses are memoized per argument hash. Server-side broadcasts like `rpc:cache:invalidate` clear entries automatically — plugins that mutate state should broadcast that message after the change. + +## Discovery (`__connection.json`) + +Devframe writes a JSON descriptor at `/__connection.json` so the client knows where to connect: + +```json +{ + "backend": "websocket", + "websocket": "ws://localhost:9999/__ws" +} +``` + +or for static mode: + +```json +{ "backend": "static" } +``` + +The client handles this for you. To override discovery (testing, advanced setups), pass `connectionMeta` directly: + +```ts +await connectDevframe({ + connectionMeta: { backend: 'static' }, +}) +``` + +## Remote docks + +Remote docks are a kit-side feature (see [Vite DevTools Kit → Remote Client](https://devtools.vite.dev/kit/remote-client)). The kit injects a connection descriptor into the iframe URL; on the hosted page, `connectDevframe` auto-detects the descriptor from the URL fragment / query string — call it as usual: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() +// Already wired to the local dev server via the injected descriptor. +``` + +The descriptor carries a session-only, pre-approved auth token, so `ensureTrusted()` resolves immediately. + +## Events + +```ts +rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { + if (isTrusted) + console.log('server trusts this client') + else + console.log('trust revoked or denied') +}) +``` + +`rpc.isTrusted` is the synchronous read. Subscribe to `rpc:is-trusted:updated` to drive reauth flows or gate rendering until the client is trusted. diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md new file mode 100644 index 0000000..3e3c17e --- /dev/null +++ b/docs/guide/devframe-definition.md @@ -0,0 +1,196 @@ +--- +outline: deep +--- + +# Devframe Definition + +Every Devframe tool starts with a single `defineDevframe` call. The returned `DevframeDefinition` is a portable value that any of the [adapters](./adapters) can consume — the same definition runs under `createCli`, `createBuild`, `createMcpServer`, kit's `createPluginFromDevframe`, and so on. + +## Minimal definition + +```ts twoslash +import { defineDevframe, defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +export default defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + icon: 'ph:gauge-duotone', + setup(ctx) { + // Register your RPC functions, shared state, etc. here. + ctx.rpc.register(defineRpcFunction({ + name: 'my-devframe:hello', + type: 'static', + jsonSerializable: true, + handler: () => ({ message: 'hello' }), + })) + }, +}) +``` + +When mounted into Vite DevTools via [`createPluginFromDevframe`](./adapters#kit), the dock entry and iframe mount are derived from `id`, `name`, `icon`, and `basePath` automatically. Hub-level features (`docks`, `terminals`, `messages`, `commands`) live on the kit-augmented context. + +## Definition fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | **Required.** Unique, namespaced identifier (kebab-case). Used as a prefix for RPC names, dock IDs, and MCP tool names. | +| `name` | `string` | **Required.** Display name shown in the dock and agent manifests. | +| `icon` | `string \| { light, dark }` | Optional Iconify name or URL; supports light/dark pairs. | +| `version` | `string` | Optional version string surfaced to clients. | +| `basePath` | `string` | Optional mount path override. Defaults depend on the adapter: `/` for standalone (`cli` / `spa` / `build`), `/./` for hosted (`vite` / `kit` / `embedded`). | +| `capabilities` | `{ dev?, build?, spa? }` | Per-runtime feature flags. A `boolean` applies to the runtime as a whole; an object enables individual features. | +| `setup` | `(ctx, info?) => void \| Promise` | **Required.** Server-side entry point. Runs in every runtime. The optional second argument carries runtime metadata — most notably the parsed CLI `flags` when running under `createCli`. | +| `setupBrowser` | `(ctx) => void \| Promise` | Browser-only entry used by the SPA adapter. | +| `cli` | `DevframeCliOptions` | Defaults for the CLI adapter. See [CLI options](#cli-options) below. | +| `spa` | `DevframeSpaOptions` | Defaults for the SPA adapter (`base`, `loader`). | + +### Runtime flags + +The `ctx.mode` field is either `'dev'` or `'build'`. Use it to gate work that should only run in one runtime: + +```ts +defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + setup(ctx) { + if (ctx.mode === 'build') { + // Static-only work — baked into the RPC dump. + } + else { + // Dev-mode wiring, file watchers, etc. + } + }, +}) +``` + +The CLI dev server sets `mode: 'dev'`; `createBuild` sets `mode: 'build'`. + +## The setup context + +`setup(ctx)` receives a `DevToolsNodeContext`: + +```ts +interface DevToolsNodeContext { + readonly cwd: string + readonly workspaceRoot: string + readonly mode: 'dev' | 'build' + + host: DevToolsHost // runtime abstraction (mountStatic / resolveOrigin / getStorageDir) + rpc: RpcFunctionsHost // register + broadcast + sharedState + views: DevToolsViewHost // static file hosting (`hostStatic`) + diagnostics: DevToolsDiagnosticsHost + agent: DevToolsAgentHost // experimental +} +``` + +Hub-level subsystems — `docks`, `terminals`, `messages`, `commands`, `createJsonRenderer` — live on the kit-augmented context owned by `@vitejs/devtools-kit`. A devframe app that wants to register kit-only behavior does so through the optional `setup` hook on `createPluginFromDevframe`. + +Each host has a dedicated page: +- [RPC](./rpc) — `ctx.rpc` +- [Shared State](./shared-state) — `ctx.rpc.sharedState` +- [Diagnostics](./diagnostics) — `ctx.diagnostics` +- [Agent-Native](./agent-native) — `ctx.agent` +- Hub-side surfaces — [Dock System](https://devtools.vite.dev/kit/dock-system), [Commands](https://devtools.vite.dev/kit/commands), [Messages](https://devtools.vite.dev/kit/messages), [Terminals](https://devtools.vite.dev/kit/terminals) — live in the [Vite DevTools Kit](https://devtools.vite.dev/kit/) docs. + +## Browser setup + +The SPA adapter supports a `setupBrowser(ctx)` hook that runs inside the deployed client bundle. Use it for tools that perform their own in-browser work — parsing a dropped file, calling public APIs from the client, etc. + +```ts +defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + setup(ctx) { /* server-side */ }, + setupBrowser(ctx) { + // `ctx.rpc` is the write-disabled static client in SPA mode. + }, +}) +``` + +Deployed SPAs that use `setupBrowser` ship their own client entry that registers the handlers. + +## CLI options + +`cli` configures the CLI adapter's defaults and plugs additional flags/commands into the CAC instance: + +```ts +defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + cli: { + command: 'my-devframe', // binary name; default: the `id` + distDir: './client/dist', // required for dev / build / spa + port: 9876, // preferred port; default: 9999 + portRange: [9876, 10000], // forwarded to get-port-please + random: false, // forwarded to get-port-please + host: 'localhost', // default host; --host overrides + open: true, // auto-open the browser on dev start + auth: false, // skip the trust handshake (single-user localhost) + configure(cli) { // contribute capability flags/commands + cli + .option('--my-flag ', 'Tool-specific flag') + }, + }, + setup(ctx, { flags }) { + // `flags` carries the parsed cac bag — contains built-in flags + // (`--port`, `--host`, `--open`, `--no-open`) and anything you added + // in `configure`. + }, +}) +``` + +| Field | Type | Description | +|-------|------|-------------| +| `command` | `string` | Binary name surfaced in `--help`. Default: the definition's `id`. | +| `distDir` | `string` | SPA dist directory. **Required** for `dev` / `build` / `spa`. | +| `port` | `number` | Preferred port for the dev server. | +| `portRange` | `[number, number]` | Port scan range, passed through to `get-port-please`. | +| `random` | `boolean` | Prefer a random open port. | +| `host` | `string` | Default bind host. | +| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. | +| `auth` | `boolean` | Disable the WS trust flow when the tool is localhost-only and single-user. Default `true`. | +| `configure` | `(cli: CAC) => void` | Contribute capability flags/commands. Runs before `createCli`'s `configureCli` option so the final tool author always has the last word. | + +`setup(ctx, info)` receives `info.flags` populated from both devframe's built-in flags and any you declared via `configure` — saves duplicating flag parsing. + +## SPA options + +```ts +defineDevframe({ + id: 'my-devframe', + spa: { + base: '/', + loader: 'query', // 'query' | 'upload' | 'none' + }, +}) +``` + +See [Adapters](./adapters) for how each adapter consumes these. + +## Multiple runtimes, one definition + +The definition is a plain value, so wire it into multiple adapters from the same file: + +```ts +import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' +import { createBuild } from 'devframe/adapters/build' +import { createCli } from 'devframe/adapters/cli' + +const devframe = defineDevframe({ id: 'my-devframe', name: 'My Devframe', setup() {} }) + +// 1. Standalone CLI: +await createCli(devframe).parse() + +// 2. Embedded in a Vite project (from `vite.config.ts`): +export const myPlugin = () => createPluginFromDevframe(devframe) + +// 3. Offline snapshot: +await createBuild(devframe, { outDir: 'dist-static' }) +``` + +## What's next + +- [Adapters](./adapters) — pick a deployment target +- [RPC](./rpc) — register server functions +- [Vite DevTools Kit](https://devtools.vite.dev/kit/) — mount your devframe into the multi-integration hub diff --git a/docs/guide/diagnostics.md b/docs/guide/diagnostics.md new file mode 100644 index 0000000..b84f9a6 --- /dev/null +++ b/docs/guide/diagnostics.md @@ -0,0 +1,160 @@ +--- +outline: deep +--- + +# Structured Diagnostics + +`ctx.diagnostics` is a thin layer over [`logs-sdk`](https://github.com/vercel-labs/logs-sdk) that lets integrations register coded errors and warnings into a shared logger without depending on `logs-sdk` directly. Use it for author-defined coded diagnostics — errors, warnings, deprecations — with a stable code, a documentation URL, and a structured payload. For free-form runtime output that should appear in the DevTools UI, use [`ctx.messages`](https://devtools.vite.dev/kit/messages). + +| Surface | Purpose | Example | +|---------|---------|---------| +| `ctx.diagnostics` | Coded errors and warnings emitted from node-side plugin code | `MYP0001: Plugin foo not configured` | +| [`ctx.messages`](https://devtools.vite.dev/kit/messages) | Free-form, user-facing notifications shown in the Messages panel | `'Audit complete — 3 issues found'` | + +## Shape + +```ts +interface DevToolsDiagnosticsHost { + /** Combined logs-sdk Logger across all registered diagnostics. */ + readonly logger: Logger + + /** Register additional diagnostic definitions. */ + register: (definitions: DiagnosticsResult) => void + + /** Re-export of logs-sdk's `defineDiagnostics`. */ + defineDiagnostics: typeof defineDiagnostics + + /** Re-export of logs-sdk's `createLogger`. */ + createLogger: typeof createLogger +} +``` + +The host ships pre-seeded with devframe's own `DF*` codes, plus the host package's codes (`DTK*` for `@vitejs/devtools`, etc.). Call `register()` to add your own. + +## Register your own codes + +```ts +export function MyPlugin(): PluginWithDevTools { + return { + name: 'my-plugin', + devtools: { + setup(ctx) { + const myDiagnostics = ctx.diagnostics.defineDiagnostics({ + docsBase: 'https://example.com/errors', + codes: { + MYP0001: { + message: (p: { name: string }) => `Plugin "${p.name}" is not configured`, + hint: 'Add the plugin to your `vite.config.ts` and pass an options object.', + }, + MYP0002: { + message: 'Cache directory missing — running cold.', + level: 'warn', + }, + }, + }) + + ctx.diagnostics.register(myDiagnostics) + + // Now you can emit codes through the shared logger: + ctx.diagnostics.logger.MYP0002().log() + }, + }, + } +} +``` + +## Code conventions + +Codes are 4-letter prefix + 4-digit number (e.g. `MYP0001`). Pick a prefix specific to your plugin or tool — short enough to type, distinctive enough to avoid collisions with other integrations. + +Prefixes already in use in this monorepo: + +| Prefix | Owner | +|--------|-------| +| `DF` | `devframe` | +| `DTK` | `@vitejs/devtools` (Vite-specific) | +| `RDDT` | `@vitejs/devtools-rolldown` | +| `VDT` | `@vitejs/devtools-vite` (reserved) | + +Each definition supports a `message` (string or function), an optional `hint`, an optional `level` (`'error'` / `'warn'` / `'suggestion'` / `'deprecation'` — defaults to `'error'`), and a `docsBase` for generating documentation URLs. See [`logs-sdk`](https://github.com/vercel-labs/logs-sdk) for the full schema. + +## Emit a diagnostic + +Each registered code becomes a callable factory on `ctx.diagnostics.logger`. The factory returns an object with `.throw()`, `.warn()`, `.error()`, `.log()`, and `.format()`. + +```ts +// Throw — control flow stops here +throw ctx.diagnostics.logger.MYP0001({ name: 'foo' }).throw() + +// Log without throwing +ctx.diagnostics.logger.MYP0002().log() + +// Override level per call +ctx.diagnostics.logger.MYP0002().warn() + +// Attach a `cause` +ctx.diagnostics.logger.MYP0001({ name: 'foo' }, { cause: error }).log() +``` + +`.throw()` is typed `never`, so TypeScript treats the line after as unreachable. Prefix the call with `throw` for control-flow narrowing: + +```ts +throw ctx.diagnostics.logger.MYP0001({ name }).throw() +``` + +## Typed logger reference + +`ctx.diagnostics.logger` is loosely typed — it covers an unbounded set of registered codes, beyond what TypeScript can narrow. For autocompletion on your plugin's specific codes, keep a typed reference returned from `createLogger`: + +```ts +const myDiagnostics = ctx.diagnostics.defineDiagnostics({ + docsBase: 'https://example.com/errors', + codes: { + MYP0001: { message: (p: { name: string }) => `…${p.name}` }, + }, +}) + +// Register so the shared logger can also see it +ctx.diagnostics.register(myDiagnostics) + +// Keep a typed reference for your own emit sites +const logger = ctx.diagnostics.createLogger({ diagnostics: [myDiagnostics] }) +logger.MYP0001({ name: 'foo' }).warn() +``` + +Both loggers share the formatter and reporter defaults set by the host (ANSI console output). + +## Updating the combined logger + +`ctx.diagnostics.logger` is a getter — it returns the freshest combined logger, rebuilt each time `register()` is called. Don't cache it: + +```ts +// ❌ Stale after a later register() call +const log = ctx.diagnostics.logger +log.MYP0001({ name: 'foo' }).log() + +// ✅ Always fresh +ctx.diagnostics.logger.MYP0001({ name: 'foo' }).log() +``` + +For a stable reference, use `ctx.diagnostics.createLogger({ diagnostics: [myDiagnostics] })` — that one stays bound to your definitions. + +## Document your codes + +Pair each code with a documentation page. devframe and the published Vite DevTools packages follow this layout: + +``` +docs/errors/ + index.md # Table of all codes + MYP0001.md # One page per code + MYP0002.md +``` + +Each page covers the message, cause, example, and fix — see any [DF code page](https://devfra.me/errors/) for the canonical template. Setting `docsBase` on `defineDiagnostics({...})` auto-attaches the URL to every emitted diagnostic. + +## When to use what + +- **`ctx.diagnostics`** — coded conditions worth looking up: misconfiguration, deprecations, validation failures, internal invariants. Always docs-backed. Often `.throw()`. +- **`ctx.messages`** — user-facing activity surfaces in the DevTools UI: progress indicators, audit results, "URL copied" toasts. Just a message and a level. + +Diagnostics target tool authors and CI; messages target the human in front of the DevTools panel. diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 0000000..39ec39b --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,159 @@ +--- +outline: deep +--- + +# Devframe + +**Devframe** is the container for one devtool integration, portable across viewers. You describe a single tool — its RPC surface, its data model, its SPA, its CLI shape — and Devframe deploys the same definition through any number of runtime adapters: a standalone CLI, a self-contained static report, an embedded SPA, an MCP server, or mounted inside a multi-integration hub. + +Devframe's surface is one tool. Hub-level features — docking, the command palette, terminal aggregation, cross-tool toasts — live in [`@vitejs/devtools-kit`](https://devtools.vite.dev/kit/). To drop a Devframe app into Vite DevTools, wrap it with `createPluginFromDevframe` from `@vitejs/devtools-kit/node`; the kit synthesises the dock entry from your definition's `id` / `name` / `icon` / `basePath` and routes the hub-level ctx fields (`docks`, `terminals`, …) accordingly. + +> [!WARNING] Experimental +> The Devframe API is still in development and may change between versions. The agent-native surface (`agent` on `defineRpcFunction`, `ctx.agent`, and the MCP adapter) is additionally flagged as experimental. + +## Design principles + +Devframe keeps its surface small and pushes hub-level UX to the kit consuming it: + +- **Single-integration scope.** Devframe describes one tool. Anything that only matters across tools — docks, palette, cross-tool toasts, unified terminals — belongs in the [DevTools Kit](https://devtools.vite.dev/kit/). +- **Headless.** Hook into `onReady`, `cli.configure`, and friends to print your own startup banners and styling — Devframe stays out of the way. +- **App-owned file watching.** Wire your own watcher (chokidar, fs.watch, …) and signal change via `ctx.rpc.sharedState.set(...)` or event-typed RPCs. +- **Context-aware mount paths.** Standalone adapters (`cli`, `spa`, `build`) serve at `/` by default; hosted adapters (`vite`, `embedded`, kit's `createPluginFromDevframe`) serve at `/./`. Override via `DevframeDefinition.basePath`. +- **SPAs own their base at runtime.** Build with relative asset paths (`vite.base: './'`); `connectDevframe` discovers the effective base from the executing script's location. +- **CLI flags compose.** The `cac` instance is exposed to both the devframe (`cli.configure`) and the caller of `createCli`, so capability flags and app flags merge cleanly. + +## What Devframe provides + +| Subsystem | What it does | +|-----------|--------------| +| **[Devframe Definition](./devframe-definition)** | One `defineDevframe` call describes your tool once; the adapters deploy it anywhere. | +| **[RPC](./rpc)** | Type-safe bidirectional calls built on birpc + valibot. Supports `query`, `static`, `action`, and `event` types. | +| **[Shared State](./shared-state)** | Observable, patch-synced state that survives reconnects and bridges server ↔ browser. | +| **[Diagnostics](./diagnostics)** | Coded warnings/errors via `logs-sdk` — registered into the host logger so adapters and consumers share the same surface. | +| **[Streaming](./streaming)** | One-way (RPC streaming) and two-way (uploads) channel primitives for long-running data. | +| **[When Clauses](./when-clauses)** | VS Code-style conditional expressions for docks, commands, and custom UI. | +| **[Utilities](./utilities)** | Bundled helpers under `devframe/utils/*` — terminal colors, hashing, editor launch, structured-clone serialization, and more. | +| **[Client](./client)** | Browser-side RPC client (`connectDevframe`) with auto-auth and WebSocket / static modes. | +| **[Agent-Native](./agent-native)** | Opt-in exposure of your tool's surface to coding agents over MCP. | + +Hub-only subsystems — [Dock System](https://devtools.vite.dev/kit/dock-system), [Commands](https://devtools.vite.dev/kit/commands), [Messages](https://devtools.vite.dev/kit/messages), [Terminals](https://devtools.vite.dev/kit/terminals) — live in the [Vite DevTools Kit](https://devtools.vite.dev/kit/) docs. + +## Architecture + +```mermaid +flowchart TB + Definition["DevframeDefinition
(defineDevframe)"] + Definition --> Adapters + + subgraph Adapters["Adapters (choose one per deployment)"] + CLI["cli"] + Vite["vite"] + Build["build"] + SPA["spa"] + Kit["kit"] + Embedded["embedded"] + MCP["mcp"] + end + + Adapters --> Ctx["DevToolsNodeContext"] + + subgraph Ctx["DevToolsNodeContext (devframe / single-integration)"] + direction TB + RPC["rpc"] + Views["views (hostStatic)"] + Diagnostics["diagnostics"] + Agent["agent"] + end + + Ctx -.->|kit augments
via createKitContext| Hub["KitNodeContext
+ docks · terminals · messages · commands"] + Ctx <-->|WebSocket or static| Client["DevToolsRpcClient
(browser)"] +``` + +## Install + +```sh +pnpm add devframe +``` + +`devframe` ships ESM-only and has no Vite dependency. Adapters with optional peers (the MCP adapter needs `@modelcontextprotocol/sdk`) surface the requirement at import time. + +## Hello, Devframe + +A minimal devframe with a CLI entry point: + +```ts twoslash +import { defineDevframe, defineRpcFunction } from 'devframe' +import { createCli } from 'devframe/adapters/cli' + +const devframe = defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + icon: 'ph:gauge-duotone', + cli: { + distDir: 'client/dist', + }, + setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'my-devframe:hello', + type: 'static', + jsonSerializable: true, + handler: () => ({ message: 'hello' }), + })) + }, +}) + +await createCli(devframe).parse() +``` + +Drop the same definition into Vite DevTools — the kit auto-derives the iframe dock entry from `id` / `name` / `icon` / `basePath`: + +```ts +// vite.config.ts +import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' +import devframe from './my-devframe' + +export default { + plugins: [createPluginFromDevframe(devframe)], +} +``` + +Run it: + +```sh +node ./my-devframe.js # dev server on http://localhost:9999/ +node ./my-devframe.js build # self-contained static deploy in dist-static/ +node ./my-devframe.js mcp # stdio MCP server (experimental) +``` + +The CLI adapter serves the SPA at `/` by default. When the same devframe is embedded inside a host (`vite`, `kit`, `embedded`), the default becomes `/.my-devframe/`. Override either side via `defineDevframe({ basePath })`. + +## Adapters at a glance + +Devframe deploys the same `DevframeDefinition` through one of these adapters: + +| Adapter | Entry | Target | +|---------|-------|--------| +| `cli` | `createCli(d).parse()` | Standalone CLI with dev / build / mcp subcommands | +| `vite` | `createVitePlugin(d, opts?)` | Plain Vite plugin that mounts the SPA | +| `build` | `createBuild(d, opts?)` | Self-contained static deploy with baked RPC dumps | +| **kit (bridge)** | `createPluginFromDevframe(d, opts?)` *(from `@vitejs/devtools-kit/node`)* | Mount the devframe into Vite DevTools' hub UI | +| `embedded` | `createEmbedded(d, { ctx })` | Runtime registration into an existing host | +| `mcp` | `createMcpServer(d, opts)` | Model Context Protocol server | + +`createPluginFromDevframe` lives in the kit because mounting into a multi-integration hub is a kit responsibility. See [Adapters](./adapters) for the full reference. + +## Dependency boundary + +Devframe is the lowest-level package in the Vite DevTools monorepo and is positioned to be extracted into its own repo. Imports from Vite or any `@vitejs/*` package are out of scope, in source and at the dependency-graph level. Hub-only concepts (docks, terminals, messages, commands) belong in the layers above: + +- `@vitejs/devtools-kit` — the hub. Owns docking, terminals, messages, and the command palette; provides `createPluginFromDevframe` to bridge a Devframe app into Vite DevTools. +- `@vitejs/devtools` — the integration. Vite plugin that wraps the kit and exposes Vite DevTools' own UI. + +For porting an existing inspector, use the [`cli`](./adapters#cli) adapter standalone and `createPluginFromDevframe` (from `@vitejs/devtools-kit/node`) to surface it inside Vite DevTools. + +## What's next + +- [Devframe Definition](./devframe-definition) — understand `defineDevframe` and the `DevToolsNodeContext` +- [Adapters](./adapters) — pick the right deployment target for your tool +- [RPC](./rpc) — define type-safe server functions your client can call +- [Agent-Native](./agent-native) — expose your devframe to Claude Desktop, Cursor, or any MCP client diff --git a/docs/guide/nuxt.md b/docs/guide/nuxt.md new file mode 100644 index 0000000..7c2cd12 --- /dev/null +++ b/docs/guide/nuxt.md @@ -0,0 +1,135 @@ +--- +outline: deep +--- + +# Nuxt Helper + +The `@devframes/nuxt` module wires a Nuxt-built SPA as a devframe client, and optionally serves the dev-time RPC bridge alongside `nuxt dev`. It runs inside the Nuxt app that consumes your devframe. + +It handles the four things every Nuxt-powered standalone devtool needs: + +1. **Base-agnostic assets.** Sets `app.baseURL: './'` and `vite.base: './'` so the same production build works at `/`, `/tool/`, and any other deployment path without build-time URL rewriting. +2. **Runtime RPC connection.** Adds a client plugin that calls [`connectDevframe()`](./client) once on page load and provides the result as `$rpc` on the Nuxt app. +3. **Dev-time RPC bridge.** When you pass `devframe`, `nuxt dev` spins up a separate WebSocket RPC server and serves `__connection.json` so the SPA can reach it — no hand-rolled Vite plugin required. +4. **TypeScript augmentation.** `useNuxtApp().$rpc` is typed as `DevToolsRpcClient` out of the box. + +## Install + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['@devframes/nuxt'], +}) +``` + +That's it for the zero-config path. The module sets sane defaults for `app.baseURL` and `vite.base`, registers the client plugin, and exposes `devframe/baseURL` on `useRuntimeConfig().public`. + +## Using `$rpc` + +```vue [app.vue] + +``` + +Or from a composable: + +```ts [composables/usePayload.ts] +export function usePayload() { + const { $rpc } = useNuxtApp() + return useAsyncData('payload', () => $rpc.call('my-tool:get-payload')) +} +``` + +## Options + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['@devframes/nuxt'], + devframe: { + baseURL: './', // where the devframe snapshot lives, relative to the page + skipAppDefaults: false, // opt out of the app.baseURL / vite.base defaults + }, +}) +``` + +- **`baseURL`** defaults to `'./'`, which resolves against `document.baseURI` at runtime. The connection meta and dump shards sit next to `index.html`, so the same build works at any deployment path. +- **`skipAppDefaults: true`** disables the `app.baseURL: './'` / `vite.base: './'` defaults. Use this when you're shipping with absolute asset paths and have your own base-URL story. + +## Dev-time RPC bridge + +Pass your devframe definition to wire `nuxt dev` up to the RPC backend: + +```ts [nuxt.config.ts] +import devframe from './src/devframe' // defineDevframe(...) export + +export default defineNuxtConfig({ + modules: [['@devframes/nuxt', { devframe }]], +}) +``` + +That's the full setup. Behind the scenes, `nuxt dev` now: + +- Starts a separate WebSocket RPC server on a port resolved via [`get-port-please`](https://github.com/unjs/get-port-please) (respects `devframe.cli.port` / `portRange` / `random`). +- Registers Vite middleware at `${baseURL}__connection.json` so the SPA reads it on load. +- Runs `devframe.setup(ctx, { flags })` once the bridge is up, registering your RPC functions. +- Cleans up the bridge on Vite restart, `nuxt dev` shutdown, and bundle close. + +The bridge is **on by default** whenever `devframe` is set. Skip it (back to client-only) with `devMiddleware: false`. + +### Customizing the bridge + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: [['@devframes/nuxt', { + devframe, + devMiddleware: { + port: 7777, + host: '0.0.0.0', + flags: { config: process.env.MY_CONFIG }, + }, + }]], +}) +``` + +- **`port`** pins the bridge port. Skip it to let `get-port-please` pick a free port. +- **`host`** controls the bridge bind host. Defaults to `nuxt.options.devServer.host ?? devframe.cli?.host ?? 'localhost'`, so `nuxt dev --host` propagates automatically. Set this manually when your Nuxt server config doesn't surface `host` (e.g. custom listen options). +- **`flags`** is forwarded to `devframe.setup(ctx, { flags })`. Use it to pass env-derived configuration into the RPC layer. + +### Relationship to `createCli` + +The bridge handles the **dev workflow**. Production deploys still go through `createCli` (or `createBuild`), which produces a static `__connection.json` + `__rpc-dump/` snapshot from `cli.distDir`: + +``` +my-tool/ +├── bin.mjs # createCli(devframe).parse() +├── src/ +│ ├── devframe.ts # defineDevframe + setup(ctx) { ctx.rpc.register(...) } +│ └── app/ # Nuxt SPA — uses `@devframes/nuxt` +└── dist/ + ├── cli.mjs # bundled Node entry + └── public/ # Nuxt build output, pointed at by cli.distDir +``` + +In dev (`nuxt dev`) the bridge is live. In production (` build` then ` spa`) the SPA loads the static dump. + +## How it works + +At build time the module: + +- Sets `nuxt.options.app.baseURL` to `'./'` (unless already set) +- Sets `nuxt.options.vite.base` to `'./'` (unless already set) +- Merges `{ devframe: { baseURL } }` into `runtimeConfig.public` +- Injects a client-only plugin (`helpers/nuxt/runtime/plugin.client`) that: + ```ts + const rpc = await connectDevframe({ baseURL: config.public.devframe.baseURL }) + return { provide: { rpc } } + ``` + +At runtime the built SPA fetches `./__connection.json` (resolved against `document.baseURI`) and branches on the `backend` field — `websocket` in dev, `static` from a `createBuild` snapshot. + +## See also + +- [Standalone CLI recipe](./standalone-cli) — end-to-end walk-through +- [Client](./client) — `connectDevframe` reference +- [Adapters](./adapters) — CLI / Vite / Build / SPA / Kit / Embedded / MCP diff --git a/docs/guide/rpc.md b/docs/guide/rpc.md new file mode 100644 index 0000000..d07d72c --- /dev/null +++ b/docs/guide/rpc.md @@ -0,0 +1,272 @@ +--- +outline: deep +--- + +# RPC + +Devframe's RPC layer is type-safe bidirectional communication between your server (Node.js) and client (browser), built on [`birpc`](https://github.com/antfu/birpc) and validated at runtime with [`valibot`](https://valibot.dev/). In dev mode it runs over WebSocket; in build / SPA mode it serves a pre-computed static dump so the client still works offline. + +## Overview + +```mermaid +sequenceDiagram + participant Client as Browser client + participant Server as Node server + + Client->>Server: rpc.call('my-devframe:get-modules') + Note over Server: handler: async () =>
readModules() + Server-->>Client: [{ id, size }, …] +``` + +## Defining a function + +```ts +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +export const getModules = defineRpcFunction({ + name: 'my-devframe:get-modules', + type: 'query', + args: [v.object({ limit: v.number() })], + returns: v.array(v.object({ id: v.string(), size: v.number() })), + setup: ctx => ({ + handler: async ({ limit }) => { + // `ctx` is the DevToolsNodeContext. + return loadModules().slice(0, limit) + }, + }), +}) +``` + +Register it in `setup`: + +```ts +import { defineDevframe } from 'devframe' +import { getModules } from './rpc/get-modules' + +export default defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + setup(ctx) { + ctx.rpc.register(getModules) + }, +}) +``` + +### Naming convention + +Scope with your devframe id and use kebab-case for the action: `my-devframe:get-modules`, `my-devframe:read-file`, `my-devframe:trigger-rebuild`. + +### Function types + +| Type | Description | Cached | Static Dump | +|------|-------------|--------|-------------| +| `query` | Read operation that can change over time. | Opt-in via `cacheable` | Manual (declare `dump`) | +| `static` | Data that never changes for a given input. | Indefinitely | Automatic | +| `action` | Mutation with side effects. | Never | Never | +| `event` | Fire-and-forget; no response. | Never | Never | + +Use `static` for data collected once during `setup` and shipped to read-only static / SPA clients. + +### Handler arguments + +Handlers accept any serializable arguments. With `args` valibot schemas, arguments are validated at the boundary: + +```ts +defineRpcFunction({ + name: 'my-devframe:get-file', + type: 'query', + args: [v.object({ path: v.string(), includeSource: v.optional(v.boolean()) })], + returns: v.object({ path: v.string(), source: v.optional(v.string()) }), + setup: () => ({ + handler: async ({ path, includeSource }) => ({ + path, + source: includeSource ? await readFile(path, 'utf-8') : undefined, + }), + }), +}) +``` + +Prefer a single object argument (`args: [v.object({ ... })]`) over positional args — property names are self-describing and agents/IDEs work best with object shapes. + +### Setup vs handler + +Two ways to wire a handler: + +- **`setup(ctx)`** — receives the `DevToolsNodeContext` and returns `{ handler, dump? }`. Use this when you need the context (shared state, logs, `ctx.mode`, etc.). +- **`handler(...)`** — shorthand when the handler is pure and doesn't touch the context. + +```ts +// With setup: +defineRpcFunction({ + name: 'my-devframe:count', + type: 'query', + setup: ctx => ({ + handler: async () => ctx.rpc.sharedState.keys().length, + }), +}) + +// Shorthand: +defineRpcFunction({ + name: 'my-devframe:echo', + type: 'query', + handler: (msg: string) => msg, +}) +``` + +## Broadcasting + +`ctx.rpc.broadcast` sends a message from the server to every connected client: + +```ts +defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + setup(ctx) { + watcher.on('change', (file) => { + void ctx.rpc.broadcast({ + method: 'my-devframe:on-file-changed', + args: [{ file }], + }) + }) + }, +}) +``` + +| Option | Type | Description | +|--------|------|-------------| +| `method` | client RPC name | Function registered on the client side. | +| `args` | any[] | Arguments passed to the client function. | +| `optional` | `boolean` | Don't throw if no client is listening. | +| `event` | `boolean` | Fire-and-forget (don't wait for responses). | +| `filter` | `(client) => boolean` | Skip specific clients. | + +## Streaming + +For chunk-style server→client feeds (chat deltas, log lines, build progress), use [streaming channels](./streaming) — they handle stream IDs, cancellation, replay, and Web Streams interop for you: + +```ts +const channel = ctx.rpc.streaming.create('my-devframe:chat', { + replayWindow: 256, +}) +const stream = channel.start() +sourceReadable.pipeTo(stream.writable) +``` + +See the [Streaming guide](./streaming) for the full API. + +## Local invocation + +`ctx.rpc.invokeLocal` calls a registered server function directly, skipping the transport — useful for cross-function composition on the server side: + +```ts +const modules = await ctx.rpc.invokeLocal('my-devframe:get-modules', { limit: 10 }) +``` + +## Client-side calls + +From the browser, [`connectDevframe`](./client) (or `getDevToolsRpcClient`) returns a client for calling registered functions: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() + +const modules = await rpc.call('my-devframe:get-modules', { limit: 10 }) +``` + +Client-side registration (for server→client calls) goes through `rpc.client.register()` — the mirror API of `ctx.rpc.register()`. + +## Static dumps + +For `static` functions, Devframe records the handler's output during `createBuild` and bakes it into the build: + +```ts +defineRpcFunction({ + name: 'my-devframe:build-meta', + type: 'static', + args: [], + returns: v.object({ version: v.string(), builtAt: v.number() }), + setup: () => ({ + handler: async () => ({ version: '1.0.0', builtAt: Date.now() }), + }), +}) +``` + +For `query` functions, provide an explicit `dump` to enumerate which argument sets to pre-compute: + +```ts +defineRpcFunction({ + name: 'my-devframe:get-session', + type: 'query', + setup: ctx => ({ + handler: async (id: string) => loadSession(id), + dump: { + inputs: [['session-a'], ['session-b']], + fallback: { id: 'unknown', data: null }, + }, + }), +}) +``` + +At runtime, static clients resolve `rpc.call('my-devframe:get-session', 'session-a')` from the baked dump; unmatched arguments resolve to `dump.fallback` (or throw without one). + +## JSON-serializable declaration + +Devframe's WS transport ships payloads using one of two encoders, picked per RPC function: + +| `jsonSerializable` | Encoder | Wire prefix | Round-trips | +|---|---|---|---| +| `false` (default) | `structured-clone-es` | `s:` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances | +| `true` (opt-in) | strict `JSON.stringify` | _(unprefixed)_ | JSON-only | + +The wire stays plain JSON when every participating function is JSON-flagged — debuggable in DevTools, friendly to MCP, and a good default for tools that already speak JSON. + +### Discovering shape errors during dev + +`jsonSerializable: true` is a contract. When a handler returns a value JSON cannot round-trip (a `Map`, a `Date`, a class instance, …), the strict serializer throws [`DF0020`](../errors/DF0020) synchronously on the offending call — surfacing the bad value next to the call site in dev: + +```ts +defineRpcFunction({ + name: 'my-devframe:graph', + jsonSerializable: true, + // ⚠ throws DF0020 because Map cannot round-trip through JSON + handler: () => ({ nodes: new Map([['a', 1]]) }), +}) +``` + +For richer types, leave the flag unset (or `false`) — `structured-clone-es` preserves them on the wire and in build dumps. The flag is opt-in, so existing code keeps working untouched. + +### MCP requires JSON + +MCP tools expose their schemas as JSON Schema, and agent harnesses assume JSON-shaped data. `agent: {...}` therefore requires `jsonSerializable: true`; registering one without the other throws [`DF0019`](../errors/DF0019). See the next section for how to attach the `agent` field once your function is JSON-safe. + +## Agent exposure + +Add an `agent` field to surface the function to coding agents over MCP. Agent exposure is opt-in; functions without an `agent` field stay private. Agent-exposed functions must also declare `jsonSerializable: true` (see above). + +```ts +defineRpcFunction({ + name: 'my-devframe:get-modules', + type: 'query', + jsonSerializable: true, + args: [v.object({ limit: v.number() })], + returns: v.array(v.object({ id: v.string(), size: v.number() })), + agent: { + description: 'List the N largest modules in the current build. Safe to call freely.', + title: 'List modules', + // safety inferred from type: 'query' → 'read' + }, + setup: () => ({ + handler: async ({ limit }) => loadModules().slice(0, limit), + }), +}) +``` + +See [Agent-Native](./agent-native) for the full safety model and MCP integration. + +## What's next + +- [Shared State](./shared-state) — observable state synced across clients +- [Client](./client) — connecting from the browser +- [Agent-Native](./agent-native) — exposing RPCs to agents diff --git a/docs/guide/shared-state.md b/docs/guide/shared-state.md new file mode 100644 index 0000000..c38602e --- /dev/null +++ b/docs/guide/shared-state.md @@ -0,0 +1,148 @@ +--- +outline: deep +--- + +# Shared State + +Shared state is observable, immutable-by-default state synced between the server and every connected client. Mutate a draft, and Devframe computes the patches to broadcast. + +Shared state survives reconnects — a newly connected client receives the current snapshot before any further updates. Use it for anything that should stay reactive. + +## Overview + +```mermaid +flowchart LR + subgraph ClientA["Client A"] + A["state.value()"] + end + subgraph Server["Server"] + S["state.mutate(fn)"] + end + subgraph ClientB["Client B"] + B["state.value()"] + end + S <-->|RPC sync| A + S <-->|RPC sync| B +``` + +## Creating state + +Use `ctx.rpc.sharedState.get(key, options)` in your `setup`: + +```ts +import { defineDevframe } from 'devframe' + +export default defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + async setup(ctx) { + const state = await ctx.rpc.sharedState.get('my-devframe:state', { + initialValue: { + count: 0, + items: [] as { id: string, name: string }[], + }, + }) + + console.log(state.value().count) // 0 + }, +}) +``` + +Namespace keys with `:` to avoid collisions when multiple devframes share a host. + +## Reading + +`state.value()` returns an immutable snapshot: + +```ts +const current = state.value() +console.log(current.count) +// current.count = 1 // ✗ TypeScript error — snapshot is Immutable +``` + +## Mutating + +Pass a recipe function to `state.mutate()`: + +```ts +state.mutate((draft) => { + draft.count += 1 + draft.items.push({ id: 'a', name: 'Alpha' }) +}) +``` + +Under the hood, Devframe: + +1. Applies the recipe to a draft of the current state, producing a new immutable snapshot. +2. Emits an `updated` event with the new state (and `SharedStatePatch[]`, if enabled). +3. Broadcasts the update to all connected clients. + +Mutations are idempotent across replay — Devframe tracks a `syncIds` set internally so a patch round-tripped back from a client applies once. + +## Patches (advanced) + +Enable patches for minimal network diffs instead of full snapshots: + +```ts +const state = await ctx.rpc.sharedState.get('my-devframe:big-state', { + initialValue: largeTree, + // sharedState-level enablePatches is opt-in: + sharedState: createSharedState({ initialValue: largeTree, enablePatches: true }), +}) +``` + +With patches enabled, the `updated` event carries a `Patch[]` alongside the new state so listeners can apply incremental updates. + +## Subscribing + +```ts +state.on('updated', (fullState, patches, syncId) => { + // `patches` is populated only when enablePatches is set. +}) +``` + +## Client-side access + +The same key is available on the RPC client in the browser: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() + +const state = await rpc.sharedState.get('my-devframe:state') + +console.log(state.value().count) + +state.mutate((draft) => { + draft.count += 1 +}) +``` + +Client-side mutations round-trip through the server before reappearing locally, so `state.value()` always reflects the authoritative source. + +## Enumerating keys + +Both server and client hosts expose `keys()` and `onKeyAdded`: + +```ts +for (const key of ctx.rpc.sharedState.keys()) { + console.log(key) +} + +const unsubscribe = ctx.rpc.sharedState.onKeyAdded((key) => { + console.log('new shared-state key:', key) +}) +``` + +Protocol adapters (the [MCP adapter](./agent-native), for example) use this to surface shared state as dynamic resources. + +## When to use shared state vs RPC + +| Use shared state for | Use RPC for | +|----------------------|-------------| +| Long-lived UI state (selections, filters, expanded nodes) | One-shot queries (`get-modules`, `read-file`) | +| Cross-client coordination | Commands / actions with side effects | +| Data that should reappear after reconnect | Event streams (prefer `broadcast` / `callEvent`) | + +For short-lived actions and events, use `ctx.rpc.register` + `ctx.rpc.broadcast` from the [RPC](./rpc) page. diff --git a/docs/guide/standalone-cli.md b/docs/guide/standalone-cli.md new file mode 100644 index 0000000..e808efd --- /dev/null +++ b/docs/guide/standalone-cli.md @@ -0,0 +1,326 @@ +--- +outline: deep +--- + +# Standalone CLI with Devframe + +This recipe walks through building a standalone CLI devframe on top of Devframe — the shape where a user runs `npx my-tool` and gets a local dev server serving a Vue / Nuxt / React SPA backed by type-safe RPC, plus `build` / `spa` / `mcp` subcommands for free. + +It's the pattern used by tools like an ESLint config inspector or a bundler-config viewer: a binary that opens a browser. + +## What you ship + +``` +my-tool/ +├── bin.mjs # shebang + import './dist/cli.mjs' +├── src/ +│ ├── cli.ts # defineDevframe + createCli +│ ├── rpc.ts # your RPC function definitions +│ └── data.ts # your domain-specific logic +├── app/ # Nuxt / Vue / React SPA source +├── dist/ +│ ├── public/ # built SPA output (served at /) +│ └── cli.mjs # bundled node entry +└── package.json +``` + +## Minimal CLI + +```ts [src/cli.ts] +import process from 'node:process' +import { defineDevframe, defineRpcFunction } from 'devframe' +import { createCli } from 'devframe/adapters/cli' +import { colors as c } from 'devframe/utils/colors' +import { resolve } from 'pathe' + +const distDir = resolve(import.meta.dirname, '../dist/public') + +const devframe = defineDevframe({ + id: 'my-tool', + name: 'My Tool', + cli: { + command: 'my-tool', + distDir, + port: 7777, + portRange: [7777, 9000], + open: true, + auth: false, // single-user localhost — skip the trust handshake + configure(cli) { + cli + .option('--config ', 'Config file path') + .option('--base-path ', 'Base directory for resolution') + }, + }, + async setup(ctx, { flags }) { + ctx.rpc.register(defineRpcFunction({ + name: 'my-tool:get-payload', + type: 'query', + async handler() { + return await loadPayload({ + configPath: flags.config, + basePath: flags.basePath, + }) + }, + })) + }, +}) + +await createCli(devframe, { + onReady({ origin }) { + console.log(c.green`My Tool ready at ${origin}`) + }, +}).parse(process.argv) +``` + +Run: + +```sh +my-tool # dev server at http://localhost:7777/ +my-tool --config ./my.config.mjs +my-tool --port 8080 --no-open +my-tool build --out-dir dist-static # self-contained static deploy +my-tool build --out-dir dist-static --base /tool/ # …under a custom base +my-tool mcp # agent exposure (experimental) +``` + +## Nuxt SPA setup + +For the Nuxt side, add the devframe helper module — it sets `app.baseURL: './'` / `vite.base: './'`, injects a client plugin that wires `connectDevframe()` into `useNuxtApp().$rpc`, and exposes the typed RPC client to the whole app: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + ssr: false, + modules: ['@devframes/nuxt'], + nitro: { + preset: 'static', + output: { dir: './dist' }, // matches createCli's distDir of ./dist/public + }, +}) +``` + +Build with `nuxt build` and point `cli.distDir` at `./dist/public`. The SPA discovers its effective base at runtime — no `--base` rewrite needed. See the [Nuxt helper docs](./nuxt) for the full reference. + +## Connecting from the client + +With the Nuxt helper installed, use `$rpc` directly: + +```ts [app/composables/payload.ts] +export async function fetchPayload() { + const { $rpc } = useNuxtApp() + return $rpc.call('my-tool:get-payload') +} +``` + +For non-Nuxt frontends (Vite + Vue, React, plain HTML, etc.), call `connectDevframe()` yourself: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() +const payload = await rpc.call('my-tool:get-payload') +``` + +`connectDevframe` auto-resolves the connection descriptor relative to the current page — it works both in dev (WebSocket backend) and in the built static snapshot (`static` backend reads the baked RPC dump). + +## Typed CLI flags + +For flags that are specific to your tool, declare them as valibot schemas so they're validated at parse time and typed at the call site: + +```ts +import type { InferCliFlags } from 'devframe/adapters/cli' +import { defineDevframe } from 'devframe' +import { defineCliFlags } from 'devframe/adapters/cli' +import * as v from 'valibot' + +const appFlags = defineCliFlags({ + depth: v.pipe(v.number(), v.integer()), + config: v.optional(v.string()), + verbose: v.optional(v.boolean()), +}) + +defineDevframe({ + id: 'my-tool', + name: 'My Tool', + cli: { + distDir, + flags: appFlags, + }, + setup(ctx, info) { + const flags = info.flags as InferCliFlags + flags.depth // number + flags.config // string | undefined + }, +}) +``` + +The adapter derives each flag's CAC option from its schema — booleans become `--verbose` / `--no-verbose`; everything else becomes `--depth `. Keys are camelCase in TypeScript, kebab-case on the command line (`configFile` → `--config-file`). Flags that aren't in your schema (`--host`, `--port`, or anything added via `cli.configure`) still pass through untouched. + +## Open helpers + +For the two actions every CLI devtool needs — open a file in the editor, reveal a path in the OS file explorer — use the prebuilt recipes instead of re-implementing them: + +```ts +import { openHelpers } from 'devframe/recipes/open-helpers' + +defineDevframe({ + id: 'my-tool', + name: 'My Tool', + setup(ctx) { + openHelpers.forEach(fn => ctx.rpc.register(fn)) + }, +}) +``` + +This registers `devframe:open-in-editor` and `devframe:open-in-finder`. Both helpers reuse the bundled [`launchEditor`](./utilities#devframe-utils-launch-editor) and [`open`](./utilities#devframe-utils-open) utilities, so there's nothing extra to install. + +## Snapshot queries for static builds + +When an RPC function's single job is to return one payload per build (no arguments that vary), set `snapshot: true` so the build adapter runs the handler once and bakes the result into the dump: + +```ts +defineRpcFunction({ + name: 'my-tool:get-payload', + type: 'query', + snapshot: true, + handler() { + return scanPackages(flags.root) + }, +}) +``` + +At build time the handler runs once with no arguments; the result is stored as both the no-args record and the fallback, so `rpc.call('my-tool:get-payload', anything)` from the deployed SPA resolves to the same snapshot. In dev mode the function behaves as a normal `query` over WebSocket — call variants with different args invoke the live handler. + +## On-disk caching + +Persistence between runs is the application's job — [`unstorage`](https://unstorage.unjs.io/) is the recommended pattern. Keep cache paths under `node_modules/.cache//` so the cache rotates with the project's `pnpm install`: + +```ts +import { resolve } from 'pathe' +import { createStorage } from 'unstorage' +import fsDriver from 'unstorage/drivers/fs' + +const cache = createStorage({ + driver: fsDriver({ + base: resolve(process.cwd(), 'node_modules/.cache/my-tool'), + }), +}) + +defineDevframe({ + id: 'my-tool', + name: 'My Tool', + async setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'my-tool:get-npm-meta', + type: 'query', + async handler(spec: string) { + return (await cache.getItem(spec)) + ?? await fetchAndCache(spec, cache) + }, + })) + }, +}) +``` + +## Live-reload on config changes + +Filesystem watching belongs to the application layer — wire your own chokidar and signal the client via shared state: + +```ts [src/cli.ts] +defineDevframe({ + id: 'my-tool', + name: 'My Tool', + async setup(ctx, { flags }) { + const payload = defineRpcFunction({ + name: 'my-tool:get-payload', + type: 'query', + cacheable: true, + handler: () => loadPayload({ configPath: flags.config }), + }) + ctx.rpc.register(payload) + + if (ctx.mode === 'dev') { + const { default: chokidar } = await import('chokidar') + const watcher = chokidar.watch(flags.config ?? [], { ignoreInitial: true }) + watcher.on('change', () => { + ctx.rpc.sharedState.set('my-tool:version', Date.now()) + }) + } + }, +}) +``` + +On the client, subscribe to the version key and refetch: + +```ts +const state = await rpc.sharedState.get('my-tool:version') +state.on('updated', () => fetchPayload().then(setData)) +``` + +## Use your own CLI framework + +`createCli` is a convenience wrapper around three lower-level factories — reach for them directly when you already own a CLI framework (commander, yargs, oclif, hand-rolled cac) or want a different command structure: + +| Building block | Entry | +|----------------|-------| +| `createDevServer(def, opts?)` | `devframe/adapters/dev` | +| `createBuild(def, opts?)` | `devframe/adapters/build` | +| `createMcpServer(def, opts?)` | `devframe/adapters/mcp` | + +Each one runs against the same `DevframeDefinition` you'd pass to `createCli`. A commander example: + +```ts [src/cli.ts] +import process from 'node:process' +import { Command } from 'commander' +import { defineDevframe } from 'devframe' +import { createBuild } from 'devframe/adapters/build' +import { createDevServer } from 'devframe/adapters/dev' + +const devframe = defineDevframe({ + id: 'my-tool', + name: 'My Tool', + cli: { distDir: './dist/public', port: 7777 }, + setup(ctx, { flags }) { /* ... */ }, +}) + +const program = new Command('my-tool') + +program + .command('dev', { isDefault: true }) + .option('-p, --port ', 'Port', '7777') + .option('--config ', 'Config file path') + .action(async (opts) => { + const handle = await createDevServer(devframe, { + port: Number(opts.port), + flags: { config: opts.config }, + onReady: ({ origin }) => console.log(`Ready at ${origin}`), + }) + process.on('SIGINT', () => handle.close().then(() => process.exit(0))) + }) + +program + .command('build') + .option('--out-dir ', 'Output directory', 'dist-static') + .action(opts => createBuild(devframe, { outDir: opts.outDir })) + +await program.parseAsync() +``` + +`createDevServer` returns the underlying `StartedServer` handle (`origin`, `port`, `app`, `wss`, `rpcGroup`, `close()`) so the surrounding program can drive graceful shutdown — SIGINT, hot reload, integration tests. + +For typed flag schemas, `parseCliFlags(schema, rawBag)` (from `devframe/adapters/cli`) validates a commander/yargs flag bag against a `CliFlagsSchema` (the same `defineCliFlags(...)` value you'd put on `cli.flags`). Typed-schema validation works with any CLI framework. + +## Why this shape + +- **One command, one binary.** `createCli` is a complete CLI — dev, build, spa, mcp all from a single `defineDevframe` value. +- **Headless.** Your `onReady` callback owns startup output, so your tool's stdout stays yours. +- **Base-agnostic.** Same SPA build works at `/` (dev, standalone static) and at any deployment base. +- **Typed end-to-end.** RPC function definitions flow their types through to the client `rpc.call` site. +- **Agent-ready.** Add `agent: { description }` to any RPC function to expose it through the `mcp` subcommand. + +## See also + +- [Devframe Definition](./devframe-definition) — field reference +- [Adapters → CLI](./adapters#cli) — full CLI adapter reference including `configureCli` and mount-path rules +- [Adapters → Dev](./adapters#dev) — `createDevServer` reference for bring-your-own-CLI integration +- [Client](./client) — `connectDevframe`, shared state, caching +- [Agent-Native](./agent-native) — exposing your tool to Claude Desktop, Cursor, etc. diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md new file mode 100644 index 0000000..9836011 --- /dev/null +++ b/docs/guide/streaming.md @@ -0,0 +1,238 @@ +--- +outline: deep +--- + +# Streaming + +Devframe's streaming-channel API provides server→client push for chunk-style data — chat deltas, log lines, build progress. It runs over the same WebSocket transport as the rest of the RPC layer and adds the conventions every chunked feed needs: stream IDs, cooperative cancellation, replay on reconnect, and first-class Web Streams interop. + +## Overview + +```mermaid +sequenceDiagram + participant Producer as Producer (server) + participant Channel as ctx.rpc.streaming
channel + participant Browser as Subscriber (browser) + + Producer->>Channel: start({ id }) + Channel-->>Browser: chunk(seq=1, "...") + Channel-->>Browser: chunk(seq=2, "...") + Producer->>Channel: close() + Channel-->>Browser: end() +``` + +A **channel** owns a wire namespace. Each call to `channel.start()` produces an individual **stream** keyed by an id (auto-generated unless you pass one). Subscribers join by `(channelName, id)`. + +## Defining a channel + +Create the channel once in `setup`. Channels are framework-neutral, so the same code works under every adapter (`cli`, `vite`, `kit`, `embedded`): + +```ts +import { defineDevframe, defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +export default defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + async setup(ctx) { + const channel = ctx.rpc.streaming.create('my-devframe:chat', { + replayWindow: 256, + }) + + ctx.rpc.register(defineRpcFunction({ + name: 'my-devframe:start-chat', + type: 'action', + jsonSerializable: true, + args: [v.object({ prompt: v.string() })], + returns: v.object({ streamId: v.string() }), + handler: async ({ prompt }) => { + const stream = channel.start() + ;(async () => { + for await (const token of fakeLLM(prompt, { signal: stream.signal })) { + stream.write(token) + } + stream.close() + })() + return { streamId: stream.id } + }, + })) + }, +}) +``` + +The channel name follows the same `:` convention as RPC functions. + +## Producing — three surfaces, one stream + +The handle returned by `channel.start({ id? })` is both an imperative producer and a Web Streams `WritableStream`: + +```ts +const stream = channel.start({ id: 'optional-explicit-id' }) + +// Imperative — minimal, hand-rolled producers +stream.write(chunk) +stream.error(err) // terminal failure +stream.close() // terminal success +stream.signal // AbortSignal — flips when consumers cancel +stream.id // string — what clients subscribe to + +// Web Streams — pipe any ReadableStream in: +sourceReadable.pipeTo(stream.writable, { signal: stream.signal }) + +// Convenience — start + pipe in one call: +const stream = await channel.pipeFrom(sourceReadable) +``` + +Producers should poll `stream.signal.aborted` and exit cooperatively when it flips: + +```ts +for (const token of source) { + if (stream.signal.aborted) + return + stream.write(token) +} +stream.close() +``` + +### Node.js stream interop + +Web Streams are the canonical surface. Node 17+ ships standard-library converters for bridging to `node:stream`: + +```ts +import { Readable, Writable } from 'node:stream' + +// Pipe a Node Readable into the streaming channel +sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) + +// Pipe the channel out to a Node Writable +Readable.fromWeb(reader.readable).pipe(targetNodeWritable) +``` + +## Consuming — `for await` or `pipeTo` + +The client returns a reader that's both an `AsyncIterable` and exposes a `ReadableStream`: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() +const { streamId } = await rpc.call('my-devframe:start-chat', { + prompt: 'Hello', +}) + +const reader = rpc.streaming.subscribe('my-devframe:chat', streamId) + +// Async iterable — the simplest consumer pattern +for await (const token of reader) + appendToken(token) + +// Or pipe to a DOM-side WritableStream +await reader.readable.pipeTo(downloadWritable) + +reader.cancel() // sends cancel upstream; server stream.signal flips +``` + +Use one surface per reader — they share a single internal queue, so concurrent draining races. + +## Lifecycle and cancellation + +| Event | Server side | Client side | +|-------|-------------|-------------| +| Producer calls `stream.close()` / `stream.error(err)` | Broadcasts `end` to subscribers | `for await` resolves (success) or throws (error) | +| Consumer calls `reader.cancel()` | Server's `stream.signal` aborts when the **last** subscriber cancels — handlers should poll and exit | Reader marks itself cancelled; `for await` ends without iterating | +| WS disconnects | When the **last** subscriber drops, server aborts `stream.signal` | Reader stays alive; resubscribes automatically when trust is re-established | +| `chat` panel closes mid-stream | Reader cancel cascades upstream | — | + +A stream with multiple subscribers stays alive until the last one cancels or disconnects. Producers should make `stream.signal.aborted` part of their inner loop. + +## Client-to-server uploads + +The same channel works in reverse for chunk-style uploads — file content, mic / screen-share frames, browser-side logs forwarded to disk, anything that would otherwise need a hand-rolled multipart-over-HTTP. The pattern: one regular RPC call allocates the id, then dedicated streaming events carry the chunks. + +```ts +// Server — typically inside an action handler +ctx.rpc.register(defineRpcFunction({ + name: 'my-devframe:upload-file', + type: 'action', + args: [v.object({ name: v.string() })], + returns: v.object({ uploadId: v.string() }), + handler: async ({ name }) => { + const reader = channel.openInbound() + + // Process chunks asynchronously — the action returns immediately + // so the client can start uploading. + ;(async () => { + const file = createWriteStream(name) + for await (const chunk of reader) + file.write(chunk) + file.close() + })() + + return { uploadId: reader.id } + }, +})) +``` + +```ts +// Client +const { uploadId } = await rpc.call('my-devframe:upload-file', { + name: 'capture.bin', +}) +const upload = rpc.streaming.upload('my-devframe:files', uploadId) + +// Imperative +upload.write(chunk1) +upload.write(chunk2) +upload.close() + +// Or pipe a Web ReadableStream straight in: +fileReadable.pipeTo(upload.writable, { signal: upload.signal }) +``` + +Lifecycle mirrors the outbound case: + +- `upload.signal` aborts when the **server** calls `reader.cancel()` (the server cancellation broadcasts an `upload-cancel` to the uploading session). +- `upload.error(err)` propagates as a thrown error inside the server's `for await`. +- If the client disconnects mid-upload, the server's `for await` exits with an `UploadDisconnected` error so consumers can clean up. + +Each `openInbound()` allocates a fresh server-side id owned by exactly one uploading session. Uploads are point-to-point: one producer, no fan-in, no shared subscribers, no replay (reconnect means the client restarts). + +## Replay on reconnect + +With `replayWindow: N`, the server keeps a rolling buffer of the last `N` chunks per stream. On (re)subscribe, the client passes the highest sequence number it has seen, and the server replays anything newer before resuming live. + +```ts +ctx.rpc.streaming.create('my-devframe:chat', { + replayWindow: 256, // chunks to retain per stream id + closedStreamRetention: 30_000, // ms to hold closed streams for late subscribers +}) +``` + +`closedStreamRetention` defaults to 30 seconds when `replayWindow > 0` (so a panel re-opened seconds after a chat finishes still gets the full transcript). Set it explicitly to tune retention. + +## Backpressure + +The client maintains a bounded queue per subscription (`highWaterMark`, default 256). When the consumer falls behind, the oldest queued chunk drops and a [`DF0029`](../errors/DF0029) warning is logged. This is best-effort — sufficient for current streaming use cases without threading transport-level backpressure through birpc. + +```ts +const reader = rpc.streaming.subscribe('my-devframe:chat', id, { + highWaterMark: 1024, // raise if you expect bursts the consumer can recover from +}) +``` + +When you need authoritative state rather than every intermediate value, [shared state](./shared-state) carries Immer patches with delivery guarantees — structured rather than streaming. + +## When to use streaming vs events vs shared state + +| Use streaming for | Use `event`-typed RPC for | Use shared state for | +|-------------------|---------------------------|----------------------| +| Token / chunk feeds (LLM deltas, build logs) | Notifications without payload (`refresh`, `clear`) | Long-lived UI state (selections, panel layout) | +| Per-call lifecycles with cancellation | Cross-cutting signals broadcast to all clients | Reactive snapshots that survive reconnect | +| Replay on reconnect | Fire-and-forget signaling | Diff-based sync between clients | +| Client-to-server uploads (files, mic frames) | | | + +## Reference + +- API surface: `RpcStreamingHost`, `RpcStreamingChannel`, `StreamSink`, `StreamReader` in `devframe/types`. +- Working example: [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat). +- Errors: [`DF0029`](../errors/DF0029) (overflow), [`DF0030`](../errors/DF0030) (unknown stream id), [`DF0031`](../errors/DF0031) (write to closed stream), [`DF0032`](../errors/DF0032) (channel name collision). diff --git a/docs/guide/utilities.md b/docs/guide/utilities.md new file mode 100644 index 0000000..69d177d --- /dev/null +++ b/docs/guide/utilities.md @@ -0,0 +1,150 @@ +--- +outline: deep +--- + +# Utilities + +Devframe ships a set of small, stable helpers under the `devframe/utils/*` subpaths. They cover the most common ancillary tasks a devtool needs — colorising terminal output, hashing arbitrary values, opening files in an editor — without forcing every author to pick (and install) their own library. + +Each helper is bundled inside devframe. Importing from `devframe/utils/*` is enough — there's no separate `npm install` for these dependencies. + +## Reference + +### `devframe/utils/colors` + +Terminal ANSI colors. Each entry is callable as a plain function or as a tagged template. + +```ts +import { colors as c } from 'devframe/utils/colors' + +console.log(c.green('Server ready')) +console.log(c.cyan`listening on port ${port}`) +console.log(`${c.bold(c.red('fatal:'))} something went wrong`) +``` + +Exports `colors` (`blue`, `cyan`, `gray`, `green`, `red`, `yellow`, `bold`, `dim`, `reset`, `underline`). + +### `devframe/utils/open` + +Open a URL, file, or other target in the OS default handler. + +```ts +import { open } from 'devframe/utils/open' + +await open('https://localhost:7777') +await open('./report.html', { wait: true }) +``` + +### `devframe/utils/launch-editor` + +Open a file in the user's editor. Target accepts `file`, `file:line`, or `file:line:column`. Pass an optional editor command (e.g. `'code'`, `'subl'`) to override the auto-detected editor. + +```ts +import { launchEditor } from 'devframe/utils/launch-editor' + +launchEditor('src/main.ts:42:7') +launchEditor('src/main.ts:42:7', 'code') +``` + +The auto-detection reads the `LAUNCH_EDITOR` environment variable and falls back to common defaults. Most devframes consume this through the prebuilt `openInEditor` recipe — see [Open helpers](./standalone-cli#open-helpers). + +### `devframe/utils/hash` + +Stable, deterministic hash of any structured-cloneable value. Useful for cache keys and dedup. + +```ts +import { hash } from 'devframe/utils/hash' + +const key = hash({ functionName, args }) +``` + +### `devframe/utils/structured-clone` + +JSON-safe serialization for the structured-clone algorithm — round-trips `Map`, `Set`, `Date`, `BigInt`, cycles, and class instances. Used internally by the RPC wire format; exposed for tools that need the same encoding. + +```ts +import { + structuredCloneDeserialize, + structuredCloneParse, + structuredCloneSerialize, + structuredCloneStringify, +} from 'devframe/utils/structured-clone' + +const wire = structuredCloneStringify(new Map([['a', 1]])) +const value = structuredCloneParse>(wire) +``` + +### `devframe/utils/human-id` + +Generate a human-readable, lowercase, dash-separated random ID. + +```ts +import { humanId } from 'devframe/utils/human-id' + +humanId() // 'bright-orange-tiger' +``` + +### `devframe/utils/nanoid` + +Tiny URL-safe random ID generator (vendored, no runtime dependency). + +```ts +import { nanoid } from 'devframe/utils/nanoid' + +nanoid() // 21 chars +nanoid(10) // 10 chars +``` + +### `devframe/utils/promise` + +Promise constructor with externally-controlled resolution. + +```ts +import { promiseWithResolver } from 'devframe/utils/promise' + +const { promise, resolve, reject } = promiseWithResolver() +``` + +### `devframe/utils/events` + +Generic typed event emitter — `on(event, cb)` returns an unsubscribe function. Used as the eventing primitive across devframe's hosts. + +```ts +import { createEventEmitter } from 'devframe/utils/events' + +const events = createEventEmitter<{ change: (n: number) => void }>() +const off = events.on('change', n => console.log(n)) +events.emit('change', 42) +off() +``` + +### `devframe/utils/shared-state` + +Underlying immutable state container used by `ctx.rpc.sharedState`. Most devframes interact with it indirectly — see [Shared State](./shared-state). Available directly when you need a state hub outside the RPC host. + +```ts +import { createSharedState } from 'devframe/utils/shared-state' + +const state = createSharedState({ initialValue: { count: 0 } }) +state.mutate((draft) => { + draft.count += 1 +}) +state.value() // { count: 1 } +``` + +### `devframe/utils/streaming-channel` + +Low-level sink/reader primitives for streamed RPC payloads. Most devframes consume these through `ctx.rpc.streaming` — see [Streaming](./streaming). + +### `devframe/utils/when` + +Statically-validated when-clause expressions for conditional UI visibility. The runtime + types ship from here; the consumer fields (`when` on docks and commands) are kit-side. See [When Clauses](./when-clauses). + +## Why a `utils/*` subpath + +The utilities are exposed as **stable wrappers over their underlying libraries** rather than bare re-exports. Two consequences: + +- **One install.** Consumers do not list these libraries in their own `package.json`. Bundling them inside devframe means version drift across devtools is impossible. +- **Swappable internals.** The wrapper signatures are deliberately narrower than upstream. Devframe can change the implementation (`ansis` → `picocolors`, `ohash` → `crypto.subtle.digest`, …) without a breaking change to dependent devtools. + +When you need a feature outside the wrapper's minimal surface, prefer extending the wrapper inside devframe over bypassing it. diff --git a/docs/guide/when-clauses.md b/docs/guide/when-clauses.md new file mode 100644 index 0000000..6bf26c0 --- /dev/null +++ b/docs/guide/when-clauses.md @@ -0,0 +1,213 @@ +--- +outline: deep +--- + +# When Clauses + +When clauses are conditional expressions that gate the visibility and executability of docks, commands, and any other UI surface you wire them into. The syntax matches [VS Code's when-clause contexts](https://code.visualstudio.com/api/references/when-clause-contexts), evaluated against a reactive context object. + +The evaluator is the external [`whenexpr`](https://github.com/antfu/whenexpr) package. Devframe re-exports `evaluateWhen`, `resolveContextValue`, and the `WhenExpression` type helper from `devframe/utils/when`. + +## Usage + +### On commands + +Controls whether the command appears in the palette and whether it can be triggered via shortcuts: + +```ts +ctx.commands.register({ + id: 'my-devtool:embedded-only', + title: 'Embedded-Only Action', + when: 'clientType == embedded', + handler: async () => { /* … */ }, +}) +``` + +### On dock entries + +Controls whether a dock is visible in the dock bar: + +```ts +ctx.docks.register({ + id: 'my-devtool:inspector', + title: 'Inspector', + type: 'action', + icon: 'ph:cursor-duotone', + when: 'clientType == embedded', + action: { importFrom: 'my-devtool/inspector' }, +}) +``` + +`when: 'false'` hides an entry unconditionally. + +## Expression syntax + +### Operators + +| Category | Operators | Example | +|----------|-----------|---------| +| Bare truthy | identifier | `dockOpen` | +| Literals | `true`, `false`, numbers, strings | `true`, `42`, `'dev'` | +| Unary | `!`, `-`, `+` | `!paletteOpen` | +| Logical | `&&`, `\|\|` | `dockOpen && !paletteOpen` | +| Equality | `==`, `!=`, `===`, `!==` | `clientType == embedded` | +| Relational | `<`, `<=`, `>`, `>=` | `count >= 10` | +| Arithmetic | `+`, `-`, `*`, `/`, `%` | `(a + b) * c` | +| Grouping | `( … )` | `(a \|\| b) && c` | + +### Precedence (low → high) + +`||` → `&&` → equality → relational → `+ -` → `* / %` → unary → primary. + +### `==` vs `===` + +- **`==` / `!=`** — VS Code when-clause idiom. The right-hand side is a single token (bare identifier, quoted string, number, or boolean); comparison is done as a string. +- **`===` / `!==`** — JavaScript strict equality. Both sides are full expressions, no coercion. + +```ts +evaluateWhen('clientType == embedded', ctx) // string-style +evaluateWhen('count === 1', { count: 1 }) // true +evaluateWhen('count === 1', { count: '1' }) // false +``` + +### Examples + +```ts +when: 'true' // always visible +when: 'false' // never visible +when: 'clientType == embedded' // only embedded +when: 'dockOpen && !paletteOpen' // dock open and palette closed +when: '(clientType == embedded && dockOpen) || clientType == standalone' +when: 'my-devtool.ready' // custom plugin context +``` + +## Built-in context variables + +| Variable | Type | Description | +|----------|------|-------------| +| `clientType` | `'embedded' \| 'standalone'` | `embedded` when running inside the host app overlay, `standalone` in a separate window. | +| `dockOpen` | `boolean` | Whether the dock panel is currently open. | +| `paletteOpen` | `boolean` | Whether the command palette is currently open. | +| `dockSelectedId` | `string` | ID of the currently selected dock entry. Empty string `''` when none. | + +## Namespaced context keys + +Plugins add keys using `.` or `:` separators: + +```ts +context['my-devtool.ready'] = true +context['my-devtool:step'] = 'build' +context.myDevtool = { ready: true, step: 'build' } // nested form +``` + +All three work in expressions: + +```ts +when: 'my-devtool.ready' +when: 'my-devtool:step == build' +when: 'myDevtool.ready' +``` + +### Lookup order + +When resolving a key like `my-devtool.ready`: + +1. Exact match — `ctx['my-devtool.ready']`. +2. Nested path — `ctx['my-devtool']?.ready`. + +Flat keys take priority when both exist. + +## Type-safe `when` clauses + +`defineCommand` and `defineDockEntry` capture `when:` as a TypeScript literal and validate it against `WhenContext` via `whenexpr`'s `WhenExpression` helper — syntax errors surface at compile time: + +```ts +import { defineCommand } from 'devframe' + +defineCommand({ + id: 'my-devtool:toggle', + title: 'Toggle', + when: 'dockOpen && !paletteOpen', // ✓ ok + handler: async () => {}, +}) + +defineCommand({ + id: 'my-devtool:broken', + title: 'Broken', + when: 'dockOpen &&& !paletteOpen', + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Type error: syntax error + handler: async () => {}, +}) +``` + +### Key validation with plugin contexts + +The default `WhenContext` keeps namespaced plugin keys open-ended (`[key: string]: unknown`); built-in syntax checking covers expression shape. For key-name validation, declare a narrower context and build a typed wrapper: + +```ts +import type { WhenContext, WhenExpression } from 'devframe/utils/when' + +interface MyPluginContext extends Omit { + 'clientType': 'embedded' | 'standalone' + 'dockOpen': boolean + 'paletteOpen': boolean + 'dockSelectedId': string + 'my-devtool.ready': boolean +} + +function defineMyCommand(cmd: { + id: string + title: string + when?: WhenExpression + handler: (...args: any[]) => Promise +}): typeof cmd { + return cmd +} + +defineMyCommand({ + id: 'my-devtool:toggle', + title: 'Toggle', + when: 'my-devtool.ready && dockOpen', // ✓ ok + handler: async () => {}, +}) + +defineMyCommand({ + id: 'my-devtool:broken', + title: 'Broken', + when: 'my-devtool.read', // ← typo + // ^^^^^^^^^^^^^^^^^^^ Type error: Unknown context key + handler: async () => {}, +}) +``` + +## API reference + +```ts +import type { WhenContext } from 'devframe/utils/when' +import { evaluateWhen, resolveContextValue } from 'devframe/utils/when' + +const ctx: WhenContext = { + 'clientType': 'embedded', + 'dockOpen': true, + 'paletteOpen': false, + 'dockSelectedId': 'my-dock', + 'my-devtool.ready': true, +} + +evaluateWhen('dockOpen && my-devtool.ready', ctx) // true +evaluateWhen('clientType == standalone', ctx) // false + +resolveContextValue('my-devtool.ready', ctx) // true +``` + +### `evaluateWhen(expression, ctx, options?)` + +Returns `boolean`. Pass `{ strict: true }` to throw on unknown keys — useful in tests to catch typos. + +### `resolveContextValue(key, ctx)` + +Returns the current value of a single (possibly namespaced) key. Used internally by the evaluator and exposed for integrations that surface live context values. + +### `WhenExpression` + +The branded expression type re-exported from `whenexpr`. Use it to build your own typed `define*` helpers — see [Key validation with plugin contexts](#key-validation-with-plugin-contexts) above. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5dd9175 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +--- +layout: home + +hero: + name: Devframe + text: Framework-neutral foundation for DevTools + tagline: One devframe definition. Seven adapters. RPC, hosts, shared state, and agent-native — independent of Vite and any UI framework. + actions: + - theme: brand + text: Get Started + link: /guide/ + - theme: alt + text: View on GitHub + link: https://github.com/devframes/devframe + +features: + - icon: 🧱 + title: One Definition, Many Deployments + details: A single `defineDevframe` call deploys to CLI, static build, SPA, Vite plugin, embedded overlay, kit host, or MCP server. + link: /guide/devframe-definition + - icon: 🔌 + title: Type-safe RPC + details: Bidirectional, schema-validated calls built on birpc + valibot. Query, static, action, and event function types. + link: /guide/rpc + - icon: 🔄 + title: Shared State + details: Observable, patch-synced state that survives reconnects and bridges server and browser with structured updates. + link: /guide/shared-state + - icon: 🌊 + title: Streaming Channels + details: One-way RPC streams and two-way upload channels for long-running data, progress reporting, and live feeds. + link: /guide/streaming + - icon: 🎨 + title: Bring Your Own UX + details: Hooks like `onReady` and `cli.configure` let your app own banners, logging, and styling while Devframe owns the plumbing. + link: /guide/ + - icon: 🤖 + title: Agent-Native (experimental) + details: Surface RPC functions, tools, and resources to coding agents over MCP with a single `agent` field on each function. + link: /guide/agent-native +--- diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..6237254 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,17 @@ +{ + "name": "devframe-docs", + "type": "module", + "private": true, + "scripts": { + "docs": "vitepress dev --port 5175", + "docs:build": "vitepress build", + "docs:serve": "vitepress serve --port 5175" + }, + "devDependencies": { + "devframe": "workspace:*", + "mermaid": "catalog:docs", + "tinyglobby": "catalog:deps", + "vitepress": "catalog:docs", + "vitepress-plugin-mermaid": "catalog:docs" + } +} diff --git a/eslint.config.js b/eslint.config.js index 45f2617..d7ff12d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,9 +1,12 @@ // @ts-check import antfu from '@antfu/eslint-config' -export default antfu( - { - type: 'lib', - pnpm: true, - }, -) +export default antfu({ + pnpm: true, + ignores: [ + 'skills', + '**/dist', + '**/.vitepress/cache', + '**/.vitepress/dist', + ], +}) diff --git a/examples/devframe-counter/bin.mjs b/examples/devframe-counter/bin.mjs new file mode 100755 index 0000000..e760d99 --- /dev/null +++ b/examples/devframe-counter/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCli } from 'devframe/adapters/cli' +import devframe from './src/devframe.ts' + +async function main() { + const cli = createCli(devframe) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/devframe-counter/package.json b/examples/devframe-counter/package.json new file mode 100644 index 0000000..5210754 --- /dev/null +++ b/examples/devframe-counter/package.json @@ -0,0 +1,18 @@ +{ + "name": "devframe-counter-example", + "type": "module", + "version": "0.1.22", + "private": true, + "description": "Smallest end-to-end devframe demo — exercises all six adapters.", + "main": "src/devframe.ts", + "bin": { + "devframe-counter": "./bin.mjs" + }, + "scripts": { + "dev": "node bin.mjs", + "build": "node bin.mjs build --out-dir dist-static" + }, + "dependencies": { + "devframe": "workspace:*" + } +} diff --git a/examples/devframe-counter/src/client/index.html b/examples/devframe-counter/src/client/index.html new file mode 100644 index 0000000..5c7d472 --- /dev/null +++ b/examples/devframe-counter/src/client/index.html @@ -0,0 +1,13 @@ + + + + + Devframe Counter + + +

Counter

+
+ + + + diff --git a/examples/devframe-counter/src/client/main.ts b/examples/devframe-counter/src/client/main.ts new file mode 100644 index 0000000..68a6e33 --- /dev/null +++ b/examples/devframe-counter/src/client/main.ts @@ -0,0 +1,21 @@ +import { connectDevframe } from 'devframe/client' + +async function main() { + const rpc = await connectDevframe() + + async function refresh() { + const result = await rpc.call('devframe-counter:get' as any) + document.getElementById('count')!.textContent = String((result as any).count) + } + + document.getElementById('bump')!.addEventListener('click', async () => { + await rpc.call('devframe-counter:increment' as any) + await refresh() + }) + + await refresh() +} + +main().catch((error) => { + console.error(error) +}) diff --git a/examples/devframe-counter/src/devframe.ts b/examples/devframe-counter/src/devframe.ts new file mode 100644 index 0000000..add4dc4 --- /dev/null +++ b/examples/devframe-counter/src/devframe.ts @@ -0,0 +1,77 @@ +import process from 'node:process' +import { defineDevframe, defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +let counter = 0 +let watchInterval: ReturnType | undefined + +export default defineDevframe({ + id: 'devframe-counter', + name: 'Devframe Counter', + icon: 'ph:counter-duotone', + async setup(ctx) { + // Static snapshot — included in the static-build dump. + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-counter:get', + type: 'static', + handler: () => ({ count: counter }), + })) + + // Action with valibot-validated input. + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-counter:increment', + type: 'action', + args: [v.object({ by: v.optional(v.number()) })], + handler: ({ by = 1 }: { by?: number }) => { + counter += by + return { count: counter } + }, + })) + + // Reactive shared state — clients see updates live via WS. + const state = await ctx.rpc.sharedState.get( + 'devframe-counter:value' as any, + { initialValue: { count: counter } as any }, + ) + + // File-watch-style auto-increment — the inspector-shape tick source + // that validates the shared-state broadcast path end-to-end. + if (ctx.mode === 'dev') { + watchInterval = setInterval(() => { + counter += 1 + state.mutate((draft: any) => { + draft.count = counter + }) + }, 5000) + } + + ctx.docks.register({ + id: 'devframe-counter', + title: 'Counter', + icon: 'ph:counter-duotone', + type: 'iframe', + url: '/devframe-counter/', + }) + }, + + // Browser-only setup for the SPA adapter — in-browser RPC handler so + // the deployed static SPA can answer `devframe-counter:get` without a + // server. (Stub until the SPA adapter lands.) + setupBrowser() { + // no-op placeholder + }, + + cli: { + command: 'devframe-counter', + port: 9999, + }, + spa: { + loader: 'query', + }, +}) + +// Graceful shutdown so nodemon / parent processes don't hang. +process.on('beforeExit', () => { + if (watchInterval) + clearInterval(watchInterval) +}) diff --git a/examples/devframe-files-inspector/.gitignore b/examples/devframe-files-inspector/.gitignore new file mode 100644 index 0000000..50760c2 --- /dev/null +++ b/examples/devframe-files-inspector/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.turbo diff --git a/examples/devframe-files-inspector/README.md b/examples/devframe-files-inspector/README.md new file mode 100644 index 0000000..450c509 --- /dev/null +++ b/examples/devframe-files-inspector/README.md @@ -0,0 +1,31 @@ +# devframe-files-inspector + +A simplified [node-modules-inspector](https://github.com/antfu/node-modules-inspector)-style example built on [devframe](../../packages/devframe). Lists files in the current working directory and renders them through a Preact SPA. Exercises every devframe surface end-to-end: + +- **CLI dev server** — `node bin.mjs` boots an HTTP + WebSocket server backing live RPC. +- **Static build** — `node bin.mjs build` produces a self-contained directory (SPA + baked RPC dump) deployable to any static host. + +The Preact client showcases two patterns relevant to devframe authors: + +1. **Runtime base discovery.** The client is built with `vite.base: './'` and reads `document.baseURI` at runtime to resolve its mount path. The same `dist/client` works under any base path (`/__devframe-files-inspector/`, `/`, `/custom/`, …) without rebuilding. +2. **Two RPC types.** `:list-files` is a `query` with `dump.inputs: [[]]` (live in dev, baked in static). `:get-cwd` is a `static` RPC. + +## Run + +```sh +pnpm install +pnpm -C examples/devframe-files-inspector run build # build the Preact client +pnpm -C examples/devframe-files-inspector run dev # http://127.0.0.1:9876/__devframe-files-inspector/ +pnpm -C examples/devframe-files-inspector run cli:build # static deploy in ./dist/static +serve examples/devframe-files-inspector/dist/static # any static host works (relative paths) +pnpm -C examples/devframe-files-inspector run test # E2E tests +``` + +## File map + +| Path | Purpose | +|------|---------| +| `src/devframe.ts` | The single `DevframeDefinition` consumed by every adapter. | +| `src/client/` | Preact SPA: `index.html`, `main.tsx`, `app.tsx`, `routes/*`, `vite.config.ts`. | +| `bin.mjs` | `createCli(devframe).parse()` — exposes `dev`, `build`, `spa`, `mcp`. | +| `tests/` | E2E tests for CLI dev server and static build. | diff --git a/examples/devframe-files-inspector/bin.mjs b/examples/devframe-files-inspector/bin.mjs new file mode 100755 index 0000000..e760d99 --- /dev/null +++ b/examples/devframe-files-inspector/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCli } from 'devframe/adapters/cli' +import devframe from './src/devframe.ts' + +async function main() { + const cli = createCli(devframe) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/devframe-files-inspector/package.json b/examples/devframe-files-inspector/package.json new file mode 100644 index 0000000..0e5719b --- /dev/null +++ b/examples/devframe-files-inspector/package.json @@ -0,0 +1,30 @@ +{ + "name": "devframe-files-inspector-example", + "type": "module", + "version": "0.1.22", + "private": true, + "description": "End-to-end devframe demo — lists files in cwd via RPC, exercises CLI dev/build/spa surfaces.", + "main": "src/devframe.ts", + "bin": { + "devframe-files-inspector": "./bin.mjs" + }, + "scripts": { + "build": "vite build --config src/client/vite.config.ts", + "dev": "node bin.mjs", + "cli:build": "node bin.mjs build --out-dir dist/static", + "test": "vitest run" + }, + "dependencies": { + "devframe": "workspace:*", + "preact": "catalog:frontend", + "tinyglobby": "catalog:deps" + }, + "devDependencies": { + "@preact/preset-vite": "catalog:build", + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "vite": "catalog:build", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/examples/devframe-files-inspector/src/client/app.tsx b/examples/devframe-files-inspector/src/client/app.tsx new file mode 100644 index 0000000..59cfcd4 --- /dev/null +++ b/examples/devframe-files-inspector/src/client/app.tsx @@ -0,0 +1,88 @@ +import type { DevToolsRpcClient } from 'devframe/client' +import { connectDevframe } from 'devframe/client' +import { useEffect, useState } from 'preact/hooks' +import { About } from './routes/about' +import { Home } from './routes/home' + +function getBasePath(): string { + return new URL(document.baseURI).pathname +} + +function getRoute(basePath: string): string { + const path = location.pathname + if (!path.startsWith(basePath)) + return '/' + const sub = path.slice(basePath.length) + return sub.startsWith('/') ? sub : `/${sub}` +} + +export function App() { + const basePath = getBasePath() + const [route, setRoute] = useState(getRoute(basePath)) + const [rpc, setRpc] = useState(null) + + useEffect(() => { + let cancelled = false + connectDevframe().then((r) => { + if (!cancelled) + setRpc(r) + }) + const onPop = () => setRoute(getRoute(basePath)) + window.addEventListener('popstate', onPop) + return () => { + cancelled = true + window.removeEventListener('popstate', onPop) + } + }, [basePath]) + + function navigate(to: string) { + const target = `${basePath}${to.replace(/^\//, '')}` + history.pushState(null, '', target) + setRoute(to) + } + + if (!rpc) + return

Connecting to devframe…

+ + return ( +
+
+

Files Inspector

+ + + base: + {' '} + {basePath} + {' | '} + backend: + {' '} + {rpc.connectionMeta.backend} + +
+
+ {route === '/about' + ? + : } +
+ ) +} diff --git a/examples/devframe-files-inspector/src/client/index.html b/examples/devframe-files-inspector/src/client/index.html new file mode 100644 index 0000000..a3b20e2 --- /dev/null +++ b/examples/devframe-files-inspector/src/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Files Inspector + + +
+ + + diff --git a/examples/devframe-files-inspector/src/client/main.tsx b/examples/devframe-files-inspector/src/client/main.tsx new file mode 100644 index 0000000..88207af --- /dev/null +++ b/examples/devframe-files-inspector/src/client/main.tsx @@ -0,0 +1,7 @@ +import { render } from 'preact' +import { App } from './app' + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') +render(, root) diff --git a/examples/devframe-files-inspector/src/client/routes/about.tsx b/examples/devframe-files-inspector/src/client/routes/about.tsx new file mode 100644 index 0000000..f6cfd0c --- /dev/null +++ b/examples/devframe-files-inspector/src/client/routes/about.tsx @@ -0,0 +1,30 @@ +import type { DevToolsRpcClient } from 'devframe/client' +import { useEffect, useState } from 'preact/hooks' + +export function About({ rpc, basePath }: { rpc: DevToolsRpcClient, basePath: string }) { + const [cwd, setCwd] = useState('') + + useEffect(() => { + rpc.call('devframe-files-inspector:get-cwd' as any).then((r: any) => { + setCwd(r.cwd) + }) + }, [rpc]) + + return ( +
+

About

+

+ This page demonstrates that the SPA discovers its mount path at + runtime — the same bundle works under any base path. +

+
+
Resolved base path
+
{basePath}
+
Server cwd
+
{cwd || '…'}
+
RPC backend
+
{rpc.connectionMeta.backend}
+
+
+ ) +} diff --git a/examples/devframe-files-inspector/src/client/routes/home.tsx b/examples/devframe-files-inspector/src/client/routes/home.tsx new file mode 100644 index 0000000..5f7dd5e --- /dev/null +++ b/examples/devframe-files-inspector/src/client/routes/home.tsx @@ -0,0 +1,42 @@ +import type { DevToolsRpcClient } from 'devframe/client' +import { useEffect, useState } from 'preact/hooks' + +export function Home({ rpc }: { rpc: DevToolsRpcClient }) { + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(true) + + async function refresh() { + setLoading(true) + try { + const result = await rpc.call('devframe-files-inspector:list-files' as any) as string[] + setFiles(result) + } + finally { + setLoading(false) + } + } + + useEffect(() => { + refresh() + }, []) + + return ( +
+

+ Files + {' '} + + ( + {files.length} + ) + +

+ +
    + {files.map(f =>
  • {f}
  • )} +
+
+ ) +} diff --git a/examples/devframe-files-inspector/src/client/vite.config.ts b/examples/devframe-files-inspector/src/client/vite.config.ts new file mode 100644 index 0000000..e19bcad --- /dev/null +++ b/examples/devframe-files-inspector/src/client/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url' +import preact from '@preact/preset-vite' +import { defineConfig } from 'vite' +import { alias } from '../../../../alias' + +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [preact()], + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/examples/devframe-files-inspector/src/devframe.ts b/examples/devframe-files-inspector/src/devframe.ts new file mode 100644 index 0000000..05eb8f9 --- /dev/null +++ b/examples/devframe-files-inspector/src/devframe.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import { defineRpcFunction } from 'devframe' +import { defineDevframe } from 'devframe/types' +import { glob } from 'tinyglobby' + +const BASE_PATH = '/__devframe-files-inspector/' +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +export default defineDevframe({ + id: 'devframe-files-inspector', + name: 'Files Inspector', + icon: 'ph:folder-open-duotone', + basePath: BASE_PATH, + cli: { + command: 'devframe-files-inspector', + port: 9876, + distDir, + }, + spa: { loader: 'none' }, + async setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-files-inspector:get-cwd', + type: 'static', + jsonSerializable: true, + handler: () => ({ cwd: ctx.cwd }), + })) + + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-files-inspector:list-files', + type: 'query', + jsonSerializable: true, + handler: async () => { + const files = await glob(['*'], { cwd: ctx.cwd, onlyFiles: true, dot: false }) + return files.map(f => f.replace(/\\/g, '/')).sort() + }, + snapshot: true, + })) + }, +}) diff --git a/examples/devframe-files-inspector/tests/_utils.ts b/examples/devframe-files-inspector/tests/_utils.ts new file mode 100644 index 0000000..5336435 --- /dev/null +++ b/examples/devframe-files-inspector/tests/_utils.ts @@ -0,0 +1,103 @@ +import type { StartedServer } from 'devframe/node' +import { existsSync } from 'node:fs' +import { mkdtemp, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + DEVTOOLS_CONNECTION_META_FILENAME, +} from 'devframe/constants' +import { + createH3DevToolsHost, + createHostContext, + startHttpAndWs, +} from 'devframe/node' +import { serveStaticHandler } from 'devframe/utils/serve-static' +import { getPort } from 'get-port-please' +import { createApp, eventHandler } from 'h3' +import { resolve } from 'pathe' +import devframe from '../src/devframe' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +export const CLIENT_DIST = resolve(HERE, '../dist/client') + +/** + * Asserts the Preact client has been built. Tests boot a dev server + * that serves files from `dist/client`; if the build is missing the + * failure message is loud enough to tell the contributor what to run. + */ +export function assertClientBuilt(): void { + if (!existsSync(path.join(CLIENT_DIST, 'index.html'))) { + throw new Error( + `[devframe-files-inspector] dist/client missing — run \`pnpm -C examples/devframe-files-inspector run build\` first.`, + ) + } +} + +/** + * Create a tmp dir seeded with a known set of files so list-files RPC + * has predictable output. + */ +export async function makeFixtureCwd(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'devframe-files-inspector-')) + await writeFile(path.join(dir, 'package.json'), '{"name":"fixture"}\n') + await writeFile(path.join(dir, 'sample.txt'), 'hello\n') + await writeFile(path.join(dir, 'README.md'), '# fixture\n') + return dir +} + +export interface InspectorServer extends StartedServer { + basePath: string +} + +/** + * Boot the inspector dev server in-process. Mirrors the cli adapter's + * runDevServer wiring so tests exercise the same RPC + static path the + * real `node bin.mjs` does, but with a controllable lifecycle. + * + * Bound to 127.0.0.1 explicitly to avoid the IPv4/IPv6 race documented + * in `packages/devframe/src/rpc/transports/ws.test.ts`. + */ +export async function startInspectorServer( + { cwd }: { cwd: string }, +): Promise { + const distDir = devframe.cli!.distDir! + const basePath = devframe.basePath! + const host = '127.0.0.1' + const port = await getPort({ host, random: true }) + + const app = createApp() + const origin = `http://${host}:${port}` + const h3Host = createH3DevToolsHost({ + origin, + appName: devframe.id, + mount: (base, dir) => { + app.use(base, serveStaticHandler(dir)) + }, + }) + + const ctx = await createHostContext({ cwd, mode: 'dev', host: h3Host }) + await devframe.setup(ctx) + + const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + app.use( + metaPath, + eventHandler((event) => { + event.node.res.setHeader('Content-Type', 'application/json') + return event.node.res.end( + JSON.stringify({ backend: 'websocket', websocket: port }), + ) + }), + ) + app.use(basePath, serveStaticHandler(resolve(distDir))) + + const server = await startHttpAndWs({ + context: ctx, + host, + port, + app, + auth: false, + }) + + return Object.assign(server, { basePath }) +} diff --git a/examples/devframe-files-inspector/tests/dev-server.test.ts b/examples/devframe-files-inspector/tests/dev-server.test.ts new file mode 100644 index 0000000..ddec511 --- /dev/null +++ b/examples/devframe-files-inspector/tests/dev-server.test.ts @@ -0,0 +1,68 @@ +import type { InspectorServer } from './_utils' +import { rm } from 'node:fs/promises' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { + assertClientBuilt, + + makeFixtureCwd, + startInspectorServer, +} from './_utils' + +vi.stubGlobal('WebSocket', WebSocket) + +describe('dev-server (CLI surface)', () => { + let cwd: string + let server: InspectorServer + + beforeAll(async () => { + assertClientBuilt() + cwd = await makeFixtureCwd() + server = await startInspectorServer({ cwd }) + }) + + afterAll(async () => { + await server?.close() + if (cwd) + await rm(cwd, { recursive: true, force: true }) + }) + + it('serves index.html with relative asset URLs at the devframe base', async () => { + const res = await fetch(`${server.origin}${server.basePath}`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('') + // Vite's `base: './'` produces `src="./assets/...js"`. + expect(html).toMatch(/src="\.\/assets\/[^"]+\.js"/) + }) + + it('serves the connection meta at the SPA root pointing at the WebSocket backend', async () => { + // The meta sits next to index.html so the SPA can discover it via a + // relative `./__connection.json` fetch — same lookup whether the + // devframe is mounted at `/`, `/__devframe-files-inspector/`, or any + // other base. + const res = await fetch( + `${server.origin}${server.basePath}__connection.json`, + ) + expect(res.status).toBe(200) + const meta = await res.json() as { backend: string, websocket: number } + expect(meta.backend).toBe('websocket') + expect(meta.websocket).toBe(server.port) + }) + + it('answers list-files and get-cwd over WebSocket RPC', async () => { + const channel = createWsRpcChannel({ + url: `ws://127.0.0.1:${server.port}`, + authToken: 'test', + }) + const rpc = createRpcClient({}, { channel }) + + const cwdResult = await rpc.$call('devframe-files-inspector:get-cwd') + expect(cwdResult).toEqual({ cwd }) + + const files = await rpc.$call('devframe-files-inspector:list-files') + expect(files).toEqual(['README.md', 'package.json', 'sample.txt']) + }) +}) diff --git a/examples/devframe-files-inspector/tests/static-build.test.ts b/examples/devframe-files-inspector/tests/static-build.test.ts new file mode 100644 index 0000000..4179485 --- /dev/null +++ b/examples/devframe-files-inspector/tests/static-build.test.ts @@ -0,0 +1,111 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createBuild } from 'devframe/adapters/build' +import { + DEVTOOLS_CONNECTION_META_FILENAME, + DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME, +} from 'devframe/constants' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import devframe from '../src/devframe' +import { assertClientBuilt, makeFixtureCwd } from './_utils' + +interface DumpManifest { + [name: string]: + | { type: 'static', path: string } + | { type: 'query', records: Record, fallback?: string } +} + +describe('static build (CLI build surface)', () => { + let cwd: string + let prevCwd: string + let outBuild: string + + beforeAll(async () => { + assertClientBuilt() + cwd = await makeFixtureCwd() + // The build adapter records `process.cwd()` for the static get-cwd + // dump; chdir into the fixture so the dump is predictable. + prevCwd = process.cwd() + process.chdir(cwd) + outBuild = await mkdtemp(path.join(os.tmpdir(), 'devframe-files-inspector-build-')) + }) + + afterAll(async () => { + process.chdir(prevCwd) + if (cwd) + await rm(cwd, { recursive: true, force: true }) + if (outBuild) + await rm(outBuild, { recursive: true, force: true }) + }) + + it('createBuild copies the SPA with relative asset URLs', async () => { + await createBuild(devframe, { outDir: outBuild }) + + const html = await readFile(path.join(outBuild, 'index.html'), 'utf-8') + expect(html).toContain('') + // Same `base: './'` smoke check — proves the bundle is mount-path + // portable. Re-deploying under any base path requires no rebuild. + expect(html).toMatch(/src="\.\/assets\/[^"]+\.js"/) + + expect(existsSync(path.join(outBuild, 'assets'))).toBe(true) + }) + + it('writes a static-backend connection meta next to index.html', async () => { + // The meta sits at the SPA root (not under `__devtools/`) so any + // generic static file server (`serve`, `nginx`, `python -m http.server`) + // can serve it as a flat tree without nested-dir exclusions. + const meta = JSON.parse( + await readFile( + path.join(outBuild, DEVTOOLS_CONNECTION_META_FILENAME), + 'utf-8', + ), + ) as { backend: string } + expect(meta).toMatchObject({ backend: 'static' }) + // Guard the design: nothing should land under a `__devtools/` subdir. + expect(existsSync(path.join(outBuild, '__devtools'))).toBe(false) + }) + + it('dumps both RPC functions into the manifest', async () => { + const manifest = JSON.parse( + await readFile( + path.join(outBuild, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME), + 'utf-8', + ), + ) as DumpManifest + + expect(manifest['devframe-files-inspector:get-cwd']).toMatchObject({ type: 'static' }) + expect(manifest['devframe-files-inspector:list-files']).toMatchObject({ type: 'query' }) + + // The list-files dump records the cwd-fixture's contents. + const listEntry = manifest['devframe-files-inspector:list-files'] + if (!('records' in listEntry)) + throw new Error('expected query manifest entry') + const recordPaths = Object.values(listEntry.records) + expect(recordPaths).toHaveLength(1) + const record = JSON.parse( + await readFile(path.join(outBuild, recordPaths[0]), 'utf-8'), + ) as { output: string[] } + expect(record.output).toEqual(['README.md', 'package.json', 'sample.txt']) + }) + + it('writes spa-loader.json honoring a custom base when def.spa is set', async () => { + // The example's devframe sets `spa: { loader: 'none' }`, which opts + // into the spa-loader sidecar. A `--base` override should be reflected + // verbatim in the loader descriptor without forcing a rebuild — the + // SPA bundle itself uses runtime base discovery, so the descriptor is + // the only place the deploy base needs to land. + const out = await mkdtemp(path.join(os.tmpdir(), 'devframe-files-inspector-base-')) + try { + await createBuild(devframe, { outDir: out, base: '/custom-base/' }) + const loader = JSON.parse( + await readFile(path.join(out, 'spa-loader.json'), 'utf-8'), + ) as { version: number, mode: string, base: string } + expect(loader).toEqual({ version: 1, mode: 'none', base: '/custom-base/' }) + } + finally { + await rm(out, { recursive: true, force: true }) + } + }) +}) diff --git a/examples/devframe-files-inspector/tests/static-serve.test.ts b/examples/devframe-files-inspector/tests/static-serve.test.ts new file mode 100644 index 0000000..8b6e5af --- /dev/null +++ b/examples/devframe-files-inspector/tests/static-serve.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { createBuild } from 'devframe/adapters/build' +import { serveStaticHandler } from 'devframe/utils/serve-static' +import { getPort } from 'get-port-please' +import { createApp, toNodeListener } from 'h3' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import devframe from '../src/devframe' +import { assertClientBuilt, makeFixtureCwd } from './_utils' + +interface StaticServer { + origin: string + /** URL prefix the SPA root is served at — `/` for option A, `/myapp/` for option B. */ + mountBase: string + close: () => Promise +} + +async function startStaticServer(outDir: string, mountBase: string): Promise { + const host = '127.0.0.1' + const port = await getPort({ host, random: true }) + const app = createApp() + app.use(mountBase, serveStaticHandler(outDir)) + const httpServer = createServer(toNodeListener(app)) + await new Promise(r => httpServer.listen(port, host, () => r())) + return { + origin: `http://${host}:${port}`, + mountBase, + close: () => new Promise(r => httpServer.close(() => r())), + } +} + +describe('static serve (deployed SPA contract)', () => { + let cwd: string + let prevCwd: string + let outDir: string + + beforeAll(async () => { + assertClientBuilt() + cwd = await makeFixtureCwd() + prevCwd = process.cwd() + process.chdir(cwd) + // macOS resolves `/var/folders/...` to `/private/var/folders/...`; + // pin the test's expected value to the realpath the build will record. + cwd = process.cwd() + outDir = await mkdtemp(path.join(os.tmpdir(), 'devframe-files-inspector-serve-')) + await createBuild(devframe, { outDir }) + }) + + afterAll(async () => { + process.chdir(prevCwd) + if (cwd) + await rm(cwd, { recursive: true, force: true }) + if (outDir) + await rm(outDir, { recursive: true, force: true }) + }) + + // The SPA discovers its connection meta + dump shards via paths + // *relative* to `document.baseURI`. For the "deployed at root" case + // those resolve to absolute URLs at the server root; for the "deployed + // at a sub-path" case the same relative paths resolve under the + // sub-path. Both must work without rebuilding the SPA — that's the + // whole point of `vite.base: './'` plus the runtime base discovery. + describe.each([ + { name: 'at server root', mountBase: '/' }, + { name: 'at a sub-path', mountBase: '/myapp/' }, + ])('mounted $name', ({ mountBase }) => { + let server: StaticServer + + beforeAll(async () => { + server = await startStaticServer(outDir, mountBase) + }) + + afterAll(async () => { + await server?.close() + }) + + it('serves index.html with relative asset URLs', async () => { + const res = await fetch(`${server.origin}${mountBase}`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('') + expect(html).toMatch(/src="\.\/assets\/[^"]+\.js"/) + }) + + it('serves the connection meta next to index.html as { backend: "static" }', async () => { + // This is the path the SPA fetches via relative `./__connection.json` + // resolved against `document.baseURI`. The 404 the user originally + // reported with `serve dist-static` was caused by the old layout + // putting this file under `/__devtools/__connection.json`, which a + // SPA at any non-`/__devtools/` mount could not discover. + const res = await fetch(`${server.origin}${mountBase}__connection.json`) + expect(res.status).toBe(200) + const meta = await res.json() as { backend: string } + expect(meta).toMatchObject({ backend: 'static' }) + }) + + it('serves the RPC dump manifest and reachable shard records', async () => { + const manifestRes = await fetch(`${server.origin}${mountBase}__rpc-dump/index.json`) + expect(manifestRes.status).toBe(200) + const manifest = await manifestRes.json() as Record + + // get-cwd: static entry → its `path` is fetchable. + const getCwdEntry = manifest['devframe-files-inspector:get-cwd'] + expect(getCwdEntry?.type).toBe('static') + const getCwdRes = await fetch(`${server.origin}${mountBase}${getCwdEntry.path}`) + expect(getCwdRes.status).toBe(200) + const getCwdRecord = await getCwdRes.json() as { output: { cwd: string } } + expect(getCwdRecord.output.cwd).toBe(cwd) + + // list-files: query entry → exactly one record matching the fixture. + const listEntry = manifest['devframe-files-inspector:list-files'] + expect(listEntry?.type).toBe('query') + const recordPaths = Object.values(listEntry.records) as string[] + expect(recordPaths).toHaveLength(1) + const recordRes = await fetch(`${server.origin}${mountBase}${recordPaths[0]}`) + expect(recordRes.status).toBe(200) + const record = await recordRes.json() as { output: string[] } + expect(record.output).toEqual(['README.md', 'package.json', 'sample.txt']) + }) + + it('does not expose a stray `__devtools/` directory at the SPA root', async () => { + // Regression guard: the build output is intentionally flat — + // re-introducing a `__devtools/` subdir would create a nested + // path the relative-base discovery in the SPA cannot reach. + const res = await fetch(`${server.origin}${mountBase}__devtools/__connection.json`) + expect(res.status).toBe(404) + }) + }) +}) diff --git a/examples/devframe-files-inspector/tsconfig.json b/examples/devframe-files-inspector/tsconfig.json new file mode 100644 index 0000000..d4bdee9 --- /dev/null +++ b/examples/devframe-files-inspector/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false + }, + "include": ["src", "tests", "bin.mjs"] +} diff --git a/examples/devframe-streaming-chat/README.md b/examples/devframe-streaming-chat/README.md new file mode 100644 index 0000000..7d4a920 --- /dev/null +++ b/examples/devframe-streaming-chat/README.md @@ -0,0 +1,72 @@ +# devframe-streaming-chat + +End-to-end demo of devframe's streaming-channel API combined with shared +state for persistent chat history. Mirrors the AI-deltas use case from +[vitejs/devtools#306](https://github.com/vitejs/devtools/issues/306): +the server emits synthesized "tokens" one at a time over a streaming +channel, while the conversation log lives in a devframe `sharedState` so +it survives reloads, syncs across panels, and replays cleanly when a +client (re)joins mid-stream. + +## What it shows + +- `ctx.rpc.streaming.create(name, opts)` registers a streaming channel. +- `ctx.rpc.sharedState.get('devframe-streaming-chat:history', …)` keeps + the message log on the server. Each `send` action appends a user + + assistant pair atomically. +- The producer streams tokens via the channel for low-latency rendering, + then commits the joined content back to the shared state when it's + done — so refreshes and new clients see the finished message + immediately. +- `reader.cancel()` aborts mid-stream; the assistant message is marked + `cancelled: true` with whatever content was accumulated. +- `replayWindow: 1024` means a panel reopened mid-stream replays the + buffered tokens before resuming live. + +## Run it + +```sh +pnpm -C devframe/examples/devframe-streaming-chat run build +pnpm -C devframe/examples/devframe-streaming-chat run dev +``` + +Then open http://localhost:9897/ — type a prompt, watch tokens stream +in, refresh the page mid-conversation, cancel a long answer, click +**Clear** to wipe the log. + +## Run the tests + +```sh +pnpm -C devframe/examples/devframe-streaming-chat run test +``` + +Tests boot the server in-process and exercise the full WS round-trip: +happy path with shared-state commit, multi-turn history, cancellation +with partial content, clear, and replay-after-finish. + +## Wire it to a real LLM + +Replace `fakeTokens(prompt)` in `src/devframe.ts` with anything that +yields strings — the rest of the example doesn't care. For OpenAI: + +```ts +const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: prompt }], +}) +for await (const chunk of response) { + if (stream.signal.aborted) + break + const token = chunk.choices[0]?.delta?.content + if (token) { + stream.write(token) + acc += token + } +} +stream.close() +``` + +`stream.signal` propagates cancellation from the browser → server → +`openai.chat.completions.create`'s own AbortController, so cancelling +also stops the upstream request. diff --git a/examples/devframe-streaming-chat/bin.mjs b/examples/devframe-streaming-chat/bin.mjs new file mode 100755 index 0000000..e760d99 --- /dev/null +++ b/examples/devframe-streaming-chat/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCli } from 'devframe/adapters/cli' +import devframe from './src/devframe.ts' + +async function main() { + const cli = createCli(devframe) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/devframe-streaming-chat/package.json b/examples/devframe-streaming-chat/package.json new file mode 100644 index 0000000..5085049 --- /dev/null +++ b/examples/devframe-streaming-chat/package.json @@ -0,0 +1,28 @@ +{ + "name": "devframe-streaming-chat-example", + "type": "module", + "version": "0.1.22", + "private": true, + "description": "End-to-end devframe demo — streams synthetic chat tokens from server to client via `ctx.rpc.streaming`. Mirrors the AI-deltas use case from issue #306.", + "main": "src/devframe.ts", + "bin": { + "devframe-streaming-chat": "./bin.mjs" + }, + "scripts": { + "build": "vite build --config src/client/vite.config.ts", + "dev": "node bin.mjs", + "test": "vitest run" + }, + "dependencies": { + "devframe": "workspace:*", + "preact": "catalog:frontend" + }, + "devDependencies": { + "@preact/preset-vite": "catalog:build", + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "vite": "catalog:build", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/examples/devframe-streaming-chat/src/client/app.tsx b/examples/devframe-streaming-chat/src/client/app.tsx new file mode 100644 index 0000000..327aae5 --- /dev/null +++ b/examples/devframe-streaming-chat/src/client/app.tsx @@ -0,0 +1,271 @@ +import type { DevToolsRpcClient } from 'devframe/client' +import type { StreamReader } from 'devframe/utils/streaming-channel' +import type { ChatHistory, ChatMessage } from '../devframe' +import { connectDevframe } from 'devframe/client' +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks' + +const CHANNEL_NAME = 'devframe-streaming-chat:tokens' +const HISTORY_KEY = 'devframe-streaming-chat:history' + +export function App() { + const [rpc, setRpc] = useState(null) + const [demoPrompts, setDemoPrompts] = useState([]) + const [messages, setMessages] = useState([]) + const [liveTokens, setLiveTokens] = useState>({}) + const [prompt, setPrompt] = useState('') + const [error, setError] = useState(null) + + const readersRef = useRef>>(new Map()) + const messagesRef = useRef(null) + + // Connect once and surface demo prompts. + useEffect(() => { + let cancelled = false + connectDevframe().then(async (r) => { + if (cancelled) + return + setRpc(r) + try { + const result = await r.call( + 'devframe-streaming-chat:demo-prompts' as any, + ) as { prompts: string[] } + if (!cancelled) + setDemoPrompts(result.prompts) + } + catch { + // demo prompts are optional + } + }) + return () => { + cancelled = true + for (const reader of readersRef.current.values()) + reader.cancel() + readersRef.current.clear() + } + }, []) + + // Bind to the server-side chat history shared state. + useEffect(() => { + if (!rpc) + return + let off: (() => void) | undefined + let active = true + rpc.sharedState + .get(HISTORY_KEY, { initialValue: { messages: [] } }) + .then((state) => { + if (!active) + return + setMessages(state.value().messages as ChatMessage[]) + off = state.on('updated', (full: ChatHistory) => { + setMessages([...full.messages]) + }) + }) + return () => { + active = false + off?.() + } + }, [rpc]) + + // For each assistant message that's currently streaming, subscribe to the + // tokens channel and accumulate into `liveTokens`. When the server commits + // the final content (`streamId` cleared), we drop the live overlay. + useEffect(() => { + if (!rpc) + return + for (const msg of messages) { + if (msg.role !== 'assistant' || !msg.streamId) + continue + if (readersRef.current.has(msg.id)) + continue + + const reader = rpc.streaming.subscribe(CHANNEL_NAME, msg.streamId) + readersRef.current.set(msg.id, reader) + setLiveTokens(prev => ({ ...prev, [msg.id]: '' })) + + ;(async () => { + try { + for await (const token of reader) { + setLiveTokens(prev => ({ + ...prev, + [msg.id]: (prev[msg.id] ?? '') + token, + })) + } + } + catch { + // Stream ended with error — leave whatever we accumulated. + } + })() + } + + // Drop overlays for messages whose stream is now committed. + setLiveTokens((prev) => { + const next = { ...prev } + let changed = false + for (const id of Object.keys(next)) { + const m = messages.find(x => x.id === id) + if (!m || !m.streamId) { + delete next[id] + readersRef.current.delete(id) + changed = true + } + } + return changed ? next : prev + }) + }, [rpc, messages]) + + // Auto-scroll on new messages / live tokens. + useEffect(() => { + const el = messagesRef.current + if (!el) + return + el.scrollTop = el.scrollHeight + }, [messages, liveTokens]) + + const activeAssistantId = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m.role === 'assistant' && m.streamId) + return m.id + } + return undefined + }, [messages]) + + const isStreaming = !!activeAssistantId + + const send = useCallback(async (text: string) => { + if (!rpc || isStreaming || !text.trim()) + return + setError(null) + setPrompt('') + try { + await rpc.call('devframe-streaming-chat:send' as any, { + prompt: text.trim(), + }) + } + catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [rpc, isStreaming]) + + const cancel = useCallback(() => { + if (!activeAssistantId) + return + const reader = readersRef.current.get(activeAssistantId) + reader?.cancel() + }, [activeAssistantId]) + + const clear = useCallback(async () => { + if (!rpc || isStreaming) + return + try { + await rpc.call('devframe-streaming-chat:clear' as any) + } + catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [rpc, isStreaming]) + + if (!rpc) + return

Connecting to devframe…

+ + return ( +
+
+
+

Streaming Chat

+ history persists in shared state · tokens stream over a channel +
+
+ +
+
+ +
+ {messages.length === 0 + ? ( +
+

No messages yet.

+

+ Type a prompt and hit + {' '} + Enter + {' '} + — or pick a demo prompt below. +

+
+ ) + : messages.map(msg => )} +
+ + {!isStreaming && demoPrompts.length > 0 && ( +
+ {demoPrompts.map(p => ( + + ))} +
+ )} + +
{ + e.preventDefault() + send(prompt) + }} + > + setPrompt((e.target as HTMLInputElement).value)} + placeholder={isStreaming ? 'Streaming reply… cancel to send another' : 'Ask anything…'} + disabled={isStreaming} + /> + {isStreaming + ? + : } +
+ +
+ backend: + {' '} + {rpc.connectionMeta.backend} + {' · '} + {messages.length} + {' '} + message + {messages.length === 1 ? '' : 's'} + {error && ( + + {' · error: '} + {error} + + )} +
+
+ ) +} + +function Message({ msg, live }: { msg: ChatMessage, live: string | undefined }) { + // Prefer the live token overlay while streaming; fall back to the + // committed content from shared state once the producer closes. + const displayed = msg.streamId !== undefined && live !== undefined + ? live + : msg.content + const cls = [ + 'msg', + `msg-${msg.role}`, + msg.streamId ? 'streaming' : '', + msg.cancelled ? 'cancelled' : '', + ].filter(Boolean).join(' ') + + return ( +
+ {displayed || (msg.streamId ? '' : '(empty)')} + {msg.cancelled &&
cancelled
} +
+ ) +} diff --git a/examples/devframe-streaming-chat/src/client/index.html b/examples/devframe-streaming-chat/src/client/index.html new file mode 100644 index 0000000..d05d989 --- /dev/null +++ b/examples/devframe-streaming-chat/src/client/index.html @@ -0,0 +1,177 @@ + + + + + + + Streaming Chat + + + +
+ + + diff --git a/examples/devframe-streaming-chat/src/client/main.tsx b/examples/devframe-streaming-chat/src/client/main.tsx new file mode 100644 index 0000000..88207af --- /dev/null +++ b/examples/devframe-streaming-chat/src/client/main.tsx @@ -0,0 +1,7 @@ +import { render } from 'preact' +import { App } from './app' + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') +render(, root) diff --git a/examples/devframe-streaming-chat/src/client/vite.config.ts b/examples/devframe-streaming-chat/src/client/vite.config.ts new file mode 100644 index 0000000..e19bcad --- /dev/null +++ b/examples/devframe-streaming-chat/src/client/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url' +import preact from '@preact/preset-vite' +import { defineConfig } from 'vite' +import { alias } from '../../../../alias' + +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [preact()], + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/examples/devframe-streaming-chat/src/devframe.ts b/examples/devframe-streaming-chat/src/devframe.ts new file mode 100644 index 0000000..09d245a --- /dev/null +++ b/examples/devframe-streaming-chat/src/devframe.ts @@ -0,0 +1,216 @@ +import { fileURLToPath } from 'node:url' +import { defineRpcFunction } from 'devframe' +import { defineDevframe } from 'devframe/types' +import { nanoid } from 'devframe/utils/nanoid' +import * as v from 'valibot' + +const BASE_PATH = '/__devframe-streaming-chat/' +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +const CHANNEL_NAME = 'devframe-streaming-chat:tokens' +const HISTORY_KEY = 'devframe-streaming-chat:history' +const MAX_HISTORY = 200 + +const DEMO_PROMPTS = [ + 'Tell me about devframe.', + 'How does streaming work?', + 'Write a haiku about RPC.', +] as const + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' + content: string + /** Set on assistant messages while their stream is in flight. */ + streamId?: string + /** True if the assistant stream was cancelled before completing. */ + cancelled?: boolean + timestamp: number +} + +export interface ChatHistory { + messages: ChatMessage[] +} + +declare module 'devframe/types' { + interface DevToolsRpcSharedStates { + [HISTORY_KEY]: ChatHistory + } +} + +/** + * Synthetic "AI" — splits a canned response into tokens and emits them + * one at a time. Swap in `OpenAI`'s `chat.completions.create({ stream: true })` + * (or any async iterable of strings) to make it real. + */ +function* fakeTokens(prompt: string): Generator { + const lower = prompt.toLowerCase() + let response: string + if (/^(?:hi|hello|hey)\b/.test(lower)) { + response = `Hello! Ask me about devframe, streaming, or anything else — I'll fake-stream a response one token at a time.` + } + else if (lower.includes('haiku')) { + response = 'Tiny chunks arrive — / type-safe over WebSocket / streams compose with ease.' + } + else if (lower.includes('streaming')) { + response + = 'Streams start with `ctx.rpc.streaming.create()` on the server. ' + + 'Producers `write()` chunks; clients subscribe and consume them via ' + + '`for await (const chunk of reader)`. Cancellation, replay, and ' + + 'backpressure are wired by the host — your handler stays small.' + } + else if (lower.includes('history') || lower.includes('persist')) { + response + = `History lives in a devframe shared state ("${HISTORY_KEY}"). ` + + 'Each `send` appends a user + assistant pair; tokens stream live, ' + + 'and the final content is committed back to the shared state when ' + + 'the producer closes. Refresh the page and the log comes back.' + } + else { + response + = `You asked: "${prompt}". ` + + 'devframe is a framework-neutral foundation for building developer ' + + 'tooling — six adapters, type-safe RPC, shared state, and a ' + + 'first-class streaming channel for delta-style server↔client data. ' + + 'Pipe `ReadableStream`s into a sink, or write chunks by hand.' + } + // Split on whitespace but keep the spaces so `tokens.join('')` round-trips. + const tokens = response.split(/(\s+)/).filter(Boolean) + for (const token of tokens) yield token +} + +export default defineDevframe({ + id: 'devframe-streaming-chat', + name: 'Streaming Chat', + icon: 'ph:chat-circle-dots-duotone', + basePath: BASE_PATH, + cli: { + command: 'devframe-streaming-chat', + port: 9897, + distDir, + // Single-user localhost demo — skip the trust handshake that the + // Vite-side surface requires. + auth: false, + }, + spa: { loader: 'none' }, + async setup(ctx) { + const channel = ctx.rpc.streaming.create(CHANNEL_NAME, { + replayWindow: 1024, + }) + + const history = await ctx.rpc.sharedState.get(HISTORY_KEY, { + initialValue: { messages: [] }, + }) + + function pruneIfTooLarge(): void { + if (history.value().messages.length > MAX_HISTORY) { + history.mutate((draft) => { + draft.messages.splice(0, draft.messages.length - MAX_HISTORY) + }) + } + } + + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-streaming-chat:demo-prompts', + type: 'static', + jsonSerializable: true, + handler: () => ({ prompts: [...DEMO_PROMPTS] }), + })) + + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-streaming-chat:send', + type: 'action', + jsonSerializable: true, + args: [v.object({ + prompt: v.string(), + intervalMs: v.optional(v.number(), 35), + })], + returns: v.object({ + userId: v.string(), + assistantId: v.string(), + streamId: v.string(), + }), + handler: async ({ prompt, intervalMs = 35 }) => { + const stream = channel.start() + const userId = nanoid() + const assistantId = nanoid() + const now = Date.now() + + // Append both messages atomically — clients see the user prompt + // and the empty assistant placeholder appear together. + history.mutate((draft) => { + draft.messages.push({ + id: userId, + role: 'user', + content: prompt, + timestamp: now, + }) + draft.messages.push({ + id: assistantId, + role: 'assistant', + content: '', + streamId: stream.id, + timestamp: now, + }) + }) + pruneIfTooLarge() + + // Producer — token-by-token via streaming, full content committed + // to shared state when done so refreshes / new clients see the + // finished message without re-streaming. + ;(async () => { + let acc = '' + let cancelled = false + try { + for (const token of fakeTokens(prompt)) { + if (stream.signal.aborted) { + cancelled = true + break + } + stream.write(token) + acc += token + await new Promise(r => setTimeout(r, intervalMs)) + } + if (!cancelled) + stream.close() + } + catch (err) { + stream.error(err) + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + msg.cancelled = true + } + }) + return + } + + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + if (cancelled) + msg.cancelled = true + } + }) + })() + + return { userId, assistantId, streamId: stream.id } + }, + })) + + ctx.rpc.register(defineRpcFunction({ + name: 'devframe-streaming-chat:clear', + type: 'action', + jsonSerializable: true, + handler: () => { + history.mutate((draft) => { + draft.messages.length = 0 + }) + }, + })) + }, +}) diff --git a/examples/devframe-streaming-chat/tests/_utils.ts b/examples/devframe-streaming-chat/tests/_utils.ts new file mode 100644 index 0000000..6dbffad --- /dev/null +++ b/examples/devframe-streaming-chat/tests/_utils.ts @@ -0,0 +1,77 @@ +import type { DevToolsNodeContext, StartedServer } from 'devframe/node' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + DEVTOOLS_CONNECTION_META_FILENAME, +} from 'devframe/constants' +import { + createH3DevToolsHost, + createHostContext, + startHttpAndWs, +} from 'devframe/node' +import { serveStaticHandler } from 'devframe/utils/serve-static' +import { getPort } from 'get-port-please' +import { createApp, eventHandler } from 'h3' +import { resolve } from 'pathe' +import devframe from '../src/devframe' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +export const CLIENT_DIST = resolve(HERE, '../dist/client') + +/** + * Boot the streaming-chat server in-process for tests. Mirrors the + * cli adapter wiring so the WS+HTTP path is exercised end-to-end. + * + * Bound to 127.0.0.1 to avoid the IPv4/IPv6 race documented in + * `packages/devframe/src/rpc/transports/ws.test.ts`. + */ +export async function startStreamingChatServer(): Promise { + // Build the client only if a test exercises the served HTML — RPC-only + // tests don't need the dist (we don't call assertClientBuilt unless the + // test fetches index.html). + const distDir = devframe.cli!.distDir! + const basePath = devframe.basePath! + const host = '127.0.0.1' + const port = await getPort({ host, random: true }) + + const app = createApp() + const origin = `http://${host}:${port}` + const h3Host = createH3DevToolsHost({ + origin, + appName: devframe.id, + mount: (base, dir) => + app.use(base, serveStaticHandler(dir)), + }) + + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host }) + await devframe.setup(ctx) + + const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + app.use( + metaPath, + eventHandler((event) => { + event.node.res.setHeader('Content-Type', 'application/json') + return event.node.res.end( + JSON.stringify({ backend: 'websocket', websocket: port }), + ) + }), + ) + if (existsSync(path.join(resolve(distDir), 'index.html'))) { + app.use(basePath, serveStaticHandler(resolve(distDir))) + } + + const server = await startHttpAndWs({ + context: ctx, + host, + port, + app, + auth: false, + }) + + return Object.assign(server, { basePath, ctx }) +} diff --git a/examples/devframe-streaming-chat/tests/streaming-chat.test.ts b/examples/devframe-streaming-chat/tests/streaming-chat.test.ts new file mode 100644 index 0000000..bc1aba4 --- /dev/null +++ b/examples/devframe-streaming-chat/tests/streaming-chat.test.ts @@ -0,0 +1,210 @@ +import type { DevToolsNodeContext, StartedServer } from 'devframe/node' +import type { ChatHistory } from '../src/devframe' +import { createRpcStreamingClientHost } from 'devframe/client' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { startStreamingChatServer } from './_utils' + +vi.stubGlobal('WebSocket', WebSocket) + +const CHANNEL = 'devframe-streaming-chat:tokens' +const HISTORY_KEY = 'devframe-streaming-chat:history' as const + +interface FakeClient { + rpc: ReturnType + streaming: ReturnType +} + +/** + * Build a minimal RPC client + streaming host. We don't go through + * `connectDevframe` because that needs a browser-like environment for + * connection-meta lookup; the WS channel is what matters for streaming. + * Shared-state syncing happens server-side, so tests inspect it through + * the harness `ctx` rather than over the wire. + */ +function bootClient(port: number): FakeClient { + const listeners = new Set<(trusted: boolean) => void>() + const fakeEvents = { + on(name: string, fn: (trusted: boolean) => void) { + if (name === 'rpc:is-trusted:updated') + listeners.add(fn) + return () => listeners.delete(fn) + }, + } + const clientFns: any = {} + const clientRpcStub = { + register(def: { name: string, handler: (...args: any[]) => any }) { + clientFns[def.name] = def.handler + }, + } + + const rpc = createRpcClient( + clientFns, + { + channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }), + }, + ) + + const fakeRpcClient = { + isTrusted: true, + events: fakeEvents, + client: clientRpcStub, + callEvent: (name: any, ...args: any[]) => (rpc as any).$callEvent(name, ...args), + } as any + + const streaming = createRpcStreamingClientHost(fakeRpcClient) + return { rpc, streaming } +} + +interface SendResult { + userId: string + assistantId: string + streamId: string +} + +async function send(client: FakeClient, prompt: string, intervalMs = 1): Promise { + return await (client.rpc as any).$call('devframe-streaming-chat:send', { + prompt, + intervalMs, + }) as SendResult +} + +async function readAll(reader: AsyncIterable): Promise { + const out: string[] = [] + for await (const chunk of reader) + out.push(chunk) + return out +} + +async function getHistory(ctx: DevToolsNodeContext): Promise { + const state = await ctx.rpc.sharedState.get(HISTORY_KEY) + return state.value() as ChatHistory +} + +describe('devframe-streaming-chat (example)', () => { + let server: StartedServer & { basePath: string, ctx: DevToolsNodeContext } + + beforeEach(async () => { + server = await startStreamingChatServer() + }) + + afterEach(async () => { + await server?.close() + }) + + it('appends user + assistant pair and commits final content to history', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { userId, assistantId, streamId } = await send(client, 'Tell me about devframe.') + const reader = client.streaming.subscribe(CHANNEL, streamId) + const tokens = await readAll(reader) + const fullText = tokens.join('') + + expect(tokens.length).toBeGreaterThan(5) + expect(fullText).toContain('You asked') + + // Wait for the post-stream sharedState mutation to land. + await vi.waitFor(async () => { + const history = await getHistory(server.ctx) + const assistant = history.messages.find(m => m.id === assistantId) + expect(assistant?.streamId).toBeUndefined() + }) + + const history = await getHistory(server.ctx) + expect(history.messages).toHaveLength(2) + expect(history.messages[0]).toMatchObject({ + id: userId, + role: 'user', + content: 'Tell me about devframe.', + }) + expect(history.messages[1]).toMatchObject({ + id: assistantId, + role: 'assistant', + content: fullText, + }) + }) + + it('persists history across multiple turns', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + for (const prompt of ['hi', 'How does streaming work?', 'Write a haiku about RPC.']) { + const { streamId } = await send(client, prompt) + await readAll(client.streaming.subscribe(CHANNEL, streamId)) + } + await new Promise(r => setTimeout(r, 50)) + + const history = await getHistory(server.ctx) + expect(history.messages).toHaveLength(6) + expect(history.messages.map(m => m.role)).toEqual([ + 'user', + 'assistant', + 'user', + 'assistant', + 'user', + 'assistant', + ]) + expect(history.messages.every(m => !m.streamId)).toBe(true) + }) + + it('cancellation marks the assistant message and saves partial content', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { assistantId, streamId } = await send(client, 'Tell me about devframe.', 30) + const reader = client.streaming.subscribe(CHANNEL, streamId) + + const collected: string[] = [] + for await (const token of reader) { + collected.push(token) + if (collected.length >= 3) + reader.cancel() + } + + await vi.waitFor(async () => { + const history = await getHistory(server.ctx) + const assistant = history.messages.find(m => m.id === assistantId) + expect(assistant?.cancelled).toBe(true) + }) + + const history = await getHistory(server.ctx) + const assistant = history.messages.find(m => m.id === assistantId)! + expect(assistant.streamId).toBeUndefined() + expect(assistant.content.length).toBeGreaterThan(0) + // Partial content — the canned "devframe" response is well over 200 chars. + expect(assistant.content.length).toBeLessThan(200) + }) + + it('clears history on demand', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { streamId } = await send(client, 'Tell me about devframe.') + await readAll(client.streaming.subscribe(CHANNEL, streamId)) + await new Promise(r => setTimeout(r, 30)) + + expect((await getHistory(server.ctx)).messages).toHaveLength(2) + + await (client.rpc as any).$call('devframe-streaming-chat:clear') + await new Promise(r => setTimeout(r, 30)) + + expect((await getHistory(server.ctx)).messages).toHaveLength(0) + }) + + it('replays buffered tokens for a late subscriber', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const { streamId } = await send(client, 'Tell me about devframe.', 5) + + // Wait for the producer to finish before subscribing. + await new Promise(r => setTimeout(r, 600)) + + const collected = await readAll(client.streaming.subscribe(CHANNEL, streamId)) + expect(collected.length).toBeGreaterThan(5) + expect(collected.join('')).toContain('You asked') + }) +}) diff --git a/examples/devframe-streaming-chat/tsconfig.json b/examples/devframe-streaming-chat/tsconfig.json new file mode 100644 index 0000000..d4bdee9 --- /dev/null +++ b/examples/devframe-streaming-chat/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false + }, + "include": ["src", "tests", "bin.mjs"] +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..93c99a6 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,6 @@ +[build] +publish = "docs/.vitepress/dist" +command = "pnpm -C docs run docs:build" + +[build.environment] +NODE_VERSION = "24" diff --git a/package.json b/package.json index d7b3b61..d87f50b 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "devframe", + "name": "devframe-monorepo", "type": "module", - "version": "0.0.0", + "version": "0.1.22", + "private": true, "packageManager": "pnpm@10.33.4", - "description": "_description_", "author": "Anthony Fu ", "license": "MIT", "funding": "https://github.com/sponsors/antfu", @@ -13,49 +13,41 @@ "url": "git+https://github.com/devframes/devframe.git" }, "bugs": "https://github.com/devframes/devframe/issues", - "keywords": [], - "sideEffects": false, - "exports": { - ".": "./dist/index.mjs", - "./package.json": "./package.json" - }, - "types": "./dist/index.d.mts", - "files": [ - "dist" - ], "scripts": { - "build": "tsdown", - "dev": "tsdown --watch", - "lint": "eslint", - "prepublishOnly": "nr build", - "release": "bumpp", - "start": "tsx src/index.ts", - "test": "pnpm run build && vitest", - "typecheck": "tsc", - "prepare": "simple-git-hooks" + "build": "turbo run build", + "watch": "pnpm -r run watch", + "docs": "pnpm -C docs run docs", + "docs:build": "pnpm -C docs run docs:build", + "docs:serve": "pnpm -C docs run docs:serve", + "lint": "eslint --cache", + "test": "turbo run build && vitest", + "release": "bumpp -r", + "typecheck": "tsc -b", + "postinstall": "npx simple-git-hooks && skills-npm" }, "devDependencies": { - "@antfu/eslint-config": "catalog:cli", - "@antfu/ni": "catalog:cli", + "@antfu/eslint-config": "catalog:devtools", + "@antfu/ni": "catalog:build", "@antfu/utils": "catalog:inlined", "@types/node": "catalog:types", - "bumpp": "catalog:cli", - "eslint": "catalog:cli", - "lint-staged": "catalog:cli", - "publint": "catalog:cli", - "simple-git-hooks": "catalog:cli", - "tsdown": "catalog:cli", - "tsdown-stale-guard": "catalog:testing", + "@types/ws": "catalog:types", + "bumpp": "catalog:devtools", + "eslint": "catalog:devtools", + "nano-staged": "catalog:devtools", + "simple-git-hooks": "catalog:devtools", + "skills-npm": "catalog:devtools", + "tsdown": "catalog:build", "tsnapi": "catalog:testing", - "tsx": "catalog:cli", - "typescript": "catalog:cli", - "vite": "catalog:cli", + "tsx": "catalog:build", + "turbo": "catalog:build", + "typescript": "catalog:devtools", + "vite": "catalog:build", "vitest": "catalog:testing" }, "simple-git-hooks": { - "pre-commit": "pnpm i --frozen-lockfile --ignore-scripts --offline && pnpm run build && npx lint-staged" + "pre-commit": "pnpm i --frozen-lockfile --ignore-scripts --offline && npx nano-staged" }, - "lint-staged": { + "nano-staged": { "*": "eslint --fix" } } diff --git a/packages/devframe/LICENSE.md b/packages/devframe/LICENSE.md new file mode 100644 index 0000000..09e688c --- /dev/null +++ b/packages/devframe/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026-PRESENT Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/devframe/README.md b/packages/devframe/README.md new file mode 100644 index 0000000..cd0dc7a --- /dev/null +++ b/packages/devframe/README.md @@ -0,0 +1,85 @@ +# devframe + +Framework-neutral foundation for building generic DevTools. Describe one devframe — its RPC, its data, its SPA, its CLI shape — and deploy the same definition through any of seven adapters. + +Part of the [Vite DevTools](https://devtools.vite.dev) monorepo. Full documentation: [https://devfra.me/](https://devfra.me/). + +## Install + +```sh +pnpm add devframe +``` + +## Hello, Devframe + +```ts +import { defineDevframe, defineRpcFunction } from 'devframe' +import { createCli } from 'devframe/adapters/cli' + +const devframe = defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'my-devframe:hello', + type: 'static', + jsonSerializable: true, + handler: () => ({ message: 'hello' }), + })) + }, +}) + +await createCli(devframe).parse() +``` + +## Adapters + +| Adapter | Use case | +|---------|----------| +| `cli` | Standalone CLI tool with `dev` / `build` / `mcp` subcommands. | +| `build` | Generates a static, self-contained SPA snapshot. | +| `vite` | Runs as a Vite plugin alongside the host app's dev server. | +| `kit` | Mounts into the DevTools Kit aggregator. | +| `embedded` | Overlays inside another devtool's UI. | +| `mcp` | Surfaces the devframe's RPC to coding agents over MCP. | + +## Agent-Native (experimental) + +> [!WARNING] +> The agent-native surface — the `agent` field on `defineRpcFunction`, `DevToolsAgentHost`, and the `devframe/adapters/mcp` adapter — may change without a major version bump until it stabilizes. + +Devframe surfaces a devframe's RPC functions, tools, and resources to coding agents over [MCP](https://modelcontextprotocol.io). Flag an RPC function with `agent: { description }` to expose it, then spin up an MCP server: + +```ts +import { defineDevframe, defineRpcFunction } from 'devframe' +import { createMcpServer } from 'devframe/adapters/mcp' + +const getSummary = defineRpcFunction({ + name: 'my-plugin:get-summary', + type: 'query', + agent: { + description: 'Return a short summary of the current build state.', + }, + setup: ctx => ({ handler: async () => buildSummary() }), +}) + +const devframe = defineDevframe({ + id: 'my-plugin', + setup(ctx) { + ctx.rpc.register(getSummary) + ctx.agent.registerResource({ + id: 'latest-build', + name: 'Latest build', + read: () => ({ text: renderMarkdown(latestBuild) }), + }) + }, +}) + +await createMcpServer(devframe, { transport: 'stdio' }) +``` + +Or via the CLI: `devframe mcp`. `@modelcontextprotocol/sdk` is a peer dependency — add it when you want MCP support. See the [Agent-Native guide](https://devfra.me/guide/agent-native) for the full API and Claude Desktop integration example. + +## License + +[MIT](./LICENSE.md) diff --git a/packages/devframe/package.json b/packages/devframe/package.json new file mode 100644 index 0000000..9c65c38 --- /dev/null +++ b/packages/devframe/package.json @@ -0,0 +1,131 @@ +{ + "name": "devframe", + "type": "module", + "version": "0.1.22", + "description": "Framework for building generic DevTools", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "packages/devframe", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devtools", + "rpc", + "toolbox" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./adapters/build": "./dist/adapters/build.mjs", + "./adapters/cli": "./dist/adapters/cli.mjs", + "./adapters/dev": "./dist/adapters/dev.mjs", + "./adapters/embedded": "./dist/adapters/embedded.mjs", + "./adapters/mcp": "./dist/adapters/mcp.mjs", + "./adapters/vite": "./dist/adapters/vite.mjs", + "./client": "./dist/client/index.mjs", + "./constants": "./dist/constants.mjs", + "./node": "./dist/node/index.mjs", + "./node/auth": "./dist/node/auth.mjs", + "./node/internal": "./dist/node/internal.mjs", + "./recipes/open-helpers": "./dist/recipes/open-helpers.mjs", + "./rpc": "./dist/rpc/index.mjs", + "./rpc/client": "./dist/rpc/client.mjs", + "./rpc/server": "./dist/rpc/server.mjs", + "./rpc/transports/ws-client": "./dist/rpc/transports/ws-client.mjs", + "./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs", + "./types": "./dist/types/index.mjs", + "./utils/colors": "./dist/utils/colors.mjs", + "./utils/events": "./dist/utils/events.mjs", + "./utils/hash": "./dist/utils/hash.mjs", + "./utils/human-id": "./dist/utils/human-id.mjs", + "./utils/launch-editor": "./dist/utils/launch-editor.mjs", + "./utils/nanoid": "./dist/utils/nanoid.mjs", + "./utils/open": "./dist/utils/open.mjs", + "./utils/promise": "./dist/utils/promise.mjs", + "./utils/serve-static": "./dist/utils/serve-static.mjs", + "./utils/shared-state": "./dist/utils/shared-state.mjs", + "./utils/streaming-channel": "./dist/utils/streaming-channel.mjs", + "./utils/structured-clone": "./dist/utils/structured-clone.mjs", + "./utils/when": "./dist/utils/when.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "prepack": "pnpm build && mkdir -p ./skills && cp -r ../../skills/devframe ./skills/devframe" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, + "dependencies": { + "@valibot/to-json-schema": "catalog:deps", + "birpc": "catalog:deps", + "cac": "catalog:deps", + "h3": "catalog:deps", + "logs-sdk": "catalog:deps", + "mrmime": "catalog:deps", + "pathe": "catalog:deps", + "valibot": "catalog:deps", + "ws": "catalog:deps" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "catalog:deps", + "ansis": "catalog:deps", + "get-port-please": "catalog:deps", + "human-id": "catalog:inlined", + "immer": "catalog:deps", + "launch-editor": "catalog:deps", + "obug": "catalog:deps", + "ohash": "catalog:deps", + "open": "catalog:deps", + "p-limit": "catalog:deps", + "perfect-debounce": "catalog:deps", + "structured-clone-es": "catalog:deps", + "tsdown": "catalog:build", + "ua-parser-modern": "catalog:inlined", + "whenexpr": "catalog:deps" + }, + "inlinedDependencies": { + "ansis": "4.2.0", + "bundle-name": "4.1.0", + "default-browser": "5.5.0", + "default-browser-id": "5.0.1", + "define-lazy-prop": "3.0.0", + "get-port-please": "3.2.0", + "human-id": "4.1.3", + "immer": "11.1.8", + "is-docker": "3.0.0", + "is-in-ssh": "1.0.0", + "is-inside-container": "1.0.0", + "is-wsl": "3.1.1", + "launch-editor": "2.13.2", + "obug": "2.1.1", + "ohash": "2.0.11", + "open": "11.0.0", + "p-limit": "7.3.0", + "perfect-debounce": "2.1.0", + "picocolors": "1.1.1", + "powershell-utils": "0.1.0", + "run-applescript": "7.1.0", + "shell-quote": "1.8.3", + "structured-clone-es": "2.0.0", + "ua-parser-modern": "0.1.1", + "whenexpr": "0.1.2", + "wsl-utils": "0.3.1", + "yocto-queue": "1.2.2" + } +} diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts new file mode 100644 index 0000000..0d445b5 --- /dev/null +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getPort } from 'get-port-please' +import { describe, expect, it } from 'vitest' +import { defineDevframe } from '../../types/devframe' +import { createDevServer, resolveDevServerPort } from '../dev' + +function makeTmpDist(): string { + const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-')) + writeFileSync(join(dir, 'index.html'), 'test', 'utf-8') + return dir +} + +describe('adapters/dev', () => { + it('createDevServer starts, exposes __connection.json, and closes', async () => { + const distDir = makeTmpDist() + const devframe = defineDevframe({ + id: 'devframe-test', + name: 'Devframe Test', + setup: () => {}, + }) + + const host = '127.0.0.1' + const port = await getPort({ port: 19999, host }) + const handle = await createDevServer(devframe, { + host, + port, + distDir, + openBrowser: false, + }) + + try { + expect(handle.port).toBe(port) + expect(handle.origin).toBe(`http://${host}:${port}`) + + const res = await fetch(`http://${host}:${port}/__connection.json`) + expect(res.ok).toBe(true) + const meta = await res.json() + expect(meta).toEqual({ backend: 'websocket', websocket: port }) + } + finally { + await handle.close() + } + }) + + it('createDevServer runs in bridge mode when no distDir is configured', async () => { + const devframe = defineDevframe({ + id: 'devframe-test-nodist', + name: 'No Dist', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19990, host }) + const handle = await createDevServer(devframe, { + host, + port, + openBrowser: false, + }) + + try { + // Connection meta is still served — the bridge endpoint that lets + // a host-served SPA discover the WS backend. + const res = await fetch(`http://${host}:${port}/__connection.json`) + expect(res.ok).toBe(true) + const meta = await res.json() + expect(meta).toEqual({ backend: 'websocket', websocket: port }) + + // The SPA mount is absent — without a distDir, no static handler + // is wired, so the basePath returns a 404 from h3 instead of an + // index.html. + const spa = await fetch(`http://${host}:${port}/`) + expect(spa.status).toBe(404) + } + finally { + await handle.close() + } + }) + + it('resolveDevServerPort honors def.cli.port as the preferred default', async () => { + const preferred = await getPort({ port: 19500, host: '127.0.0.1' }) + const devframe = defineDevframe({ + id: 'devframe-test-port', + name: 'Port Test', + setup: () => {}, + cli: { port: preferred }, + }) + const port = await resolveDevServerPort(devframe, { host: '127.0.0.1' }) + expect(port).toBe(preferred) + }) + + it('resolveDevServerPort: defaultPort overrides def.cli.port', async () => { + const override = await getPort({ port: 19600, host: '127.0.0.1' }) + const devframe = defineDevframe({ + id: 'devframe-test-port-override', + name: 'Port Override', + setup: () => {}, + cli: { port: 9999 }, + }) + const port = await resolveDevServerPort(devframe, { + host: '127.0.0.1', + defaultPort: override, + }) + expect(port).toBe(override) + }) +}) diff --git a/packages/devframe/src/adapters/__tests__/flags.test.ts b/packages/devframe/src/adapters/__tests__/flags.test.ts new file mode 100644 index 0000000..b08310c --- /dev/null +++ b/packages/devframe/src/adapters/__tests__/flags.test.ts @@ -0,0 +1,71 @@ +import * as v from 'valibot' +import { describe, expect, it } from 'vitest' +import { defineCliFlags, flagKeyToOption, isBooleanFlag, parseCliFlags } from '../flags' + +describe('adapters/flags', () => { + it('defineCliFlags returns the input untouched (identity)', () => { + const schema = { depth: v.number() } + expect(defineCliFlags(schema)).toBe(schema) + }) + + it('kebab-cases camelCase keys for CAC option names', () => { + expect(flagKeyToOption('depth')).toBe('depth') + expect(flagKeyToOption('noOpen')).toBe('no-open') + expect(flagKeyToOption('configFile')).toBe('config-file') + }) + + it('detects boolean schemas (including optional/pipe wrappers)', () => { + expect(isBooleanFlag(v.boolean())).toBe(true) + expect(isBooleanFlag(v.optional(v.boolean()))).toBe(true) + expect(isBooleanFlag(v.nullish(v.boolean()))).toBe(true) + expect(isBooleanFlag(v.pipe(v.boolean()))).toBe(true) + expect(isBooleanFlag(v.string())).toBe(false) + expect(isBooleanFlag(v.number())).toBe(false) + }) + + describe('parseCliFlags', () => { + const schema = defineCliFlags({ + depth: v.pipe(v.number(), v.integer()), + config: v.optional(v.string()), + verbose: v.optional(v.boolean()), + }) + + it('returns typed values when all inputs validate', () => { + const { flags, issues } = parseCliFlags(schema, { + depth: 8, + config: './my.config.ts', + verbose: true, + }) + expect(issues).toBeUndefined() + expect(flags).toEqual({ + depth: 8, + config: './my.config.ts', + verbose: true, + }) + }) + + it('allows optional flags to be omitted', () => { + const { flags, issues } = parseCliFlags(schema, { depth: 4 }) + expect(issues).toBeUndefined() + expect(flags.depth).toBe(4) + expect(flags.config).toBeUndefined() + expect(flags.verbose).toBeUndefined() + }) + + it('surfaces validation issues with kebab-cased flag names', () => { + const { issues } = parseCliFlags(schema, { depth: 'not-a-number' }) + expect(issues).toBeDefined() + expect(issues!.some(i => i.startsWith('--depth:'))).toBe(true) + }) + + it('preserves raw flags that are not in the schema (host/port escape hatch)', () => { + const { flags } = parseCliFlags(schema, { + depth: 2, + host: '127.0.0.1', + port: 9000, + }) + expect(flags.host).toBe('127.0.0.1') + expect(flags.port).toBe(9000) + }) + }) +}) diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts new file mode 100644 index 0000000..4db9b6f --- /dev/null +++ b/packages/devframe/src/adapters/_shared.ts @@ -0,0 +1,22 @@ +import type { DevframeDefinition, DevframeDeploymentKind } from '../types/devframe' + +/** + * Resolve the mount base path for a devframe's SPA. Hosted adapters + * (`vite`, `kit`, `embedded`) default to `/__/` so they don't + * collide with the host app; standalone adapters (`cli`, `spa`, + * `build`) default to `/` because they own the origin. + * + * The devframe author can override with `basePath` on the definition. + */ +export function resolveBasePath(def: DevframeDefinition, kind: DevframeDeploymentKind): string { + if (def.basePath) + return normalizeBasePath(def.basePath) + return kind === 'standalone' ? '/' : `/__${def.id}/` +} + +export function normalizeBasePath(base: string): string { + let out = base.startsWith('/') ? base : `/${base}` + if (!out.endsWith('/')) + out = `${out}/` + return out.replace(/\/+/g, '/') +} diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts new file mode 100644 index 0000000..735cc40 --- /dev/null +++ b/packages/devframe/src/adapters/build.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-console */ +import type { DevframeDefinition } from '../types/devframe' +import { existsSync } from 'node:fs' +import fs from 'node:fs/promises' +import process from 'node:process' +import { dirname, resolve } from 'pathe' +import { + DEVTOOLS_CONNECTION_META_FILENAME, + DEVTOOLS_RPC_DUMP_DIRNAME, + DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME, +} from '../constants' +import { createHostContext } from '../node/context' +import { createH3DevToolsHost } from '../node/host-h3' +import { collectStaticRpcDump } from '../node/static-dump' +import { strictJsonStringify } from '../rpc/serialization' +import { colors as c } from '../utils/colors' +import { structuredCloneStringify } from '../utils/structured-clone' +import { resolveBasePath } from './_shared' + +export interface CreateBuildOptions { + /** Output directory. Defaults to `dist-static`. */ + outDir?: string + /** Absolute URL base the output is served from (default: `/`). */ + base?: string + /** + * Override the SPA dist directory to copy into `outDir`. When omitted + * the adapter reads `devframe.cli?.distDir` — authors typically set this + * once on the definition itself. + */ + distDir?: string + /** + * Pretty-print RPC dump JSON files. Defaults to `false` so payload + * shards (which can be multiple MB for graph-heavy tools) ship + * minified. Set `true` when you need to diff / read the dumps by hand. + */ + pretty?: boolean +} + +/** + * Produce a self-contained static deploy of a devframe: + * + * - Build a `mode: 'build'` context and run `devframe.setup(ctx)`. + * - Copy the author's SPA dist into `/`. + * - Write `/__connection.json` (`{ backend: 'static' }`) and the + * sharded RPC dump under `/__rpc-dump/` so the deployed SPA + * discovers both via relative paths from `document.baseURI`. + * - When `def.spa` is configured, also write `/spa-loader.json` + * describing the SPA's data-loader mode (`'query'` / `'upload'` / + * `'none'`). The output is mount-path agnostic — the same bundle + * works at `/`, `/devtools/`, or any base, no rewriting required. + */ +export async function createBuild(d: DevframeDefinition, options: CreateBuildOptions = {}): Promise { + const outDir = resolve(options.outDir ?? 'dist-static') + const distDir = options.distDir ?? d.cli?.distDir + if (!distDir) + throw new Error(`[devframe] createBuild: no distDir for "${d.id}". Set \`cli.distDir\` on the definition or pass it as an option.`) + + if (existsSync(outDir)) + await fs.rm(outDir, { recursive: true }) + await fs.mkdir(outDir, { recursive: true }) + + // Copy author's SPA into the output root. + console.log(c.cyan`[devframe] copying SPA from ${distDir} -> ${outDir}`) + await fs.cp(distDir, outDir, { recursive: true }) + + const ctx = await createHostContext({ + cwd: process.cwd(), + mode: 'build', + host: createH3DevToolsHost({ origin: 'http://localhost', appName: d.id }), + }) + await d.setup(ctx) + + await fs.mkdir(resolve(outDir, DEVTOOLS_RPC_DUMP_DIRNAME), { recursive: true }) + + const jsonSerializableMethods: string[] = [] + for (const def of ctx.rpc.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } + await fs.writeFile( + resolve(outDir, DEVTOOLS_CONNECTION_META_FILENAME), + JSON.stringify({ backend: 'static', jsonSerializableMethods }, null, 2), + 'utf-8', + ) + + console.log(c.cyan`[devframe] writing RPC dump to ${resolve(outDir, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME)}`) + const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) + const indent = options.pretty ? 2 : undefined + for (const [filepath, file] of Object.entries(dump.files)) { + const fullpath = resolve(outDir, filepath) + await fs.mkdir(dirname(fullpath), { recursive: true }) + const text = file.serialization === 'structured-clone' + ? structuredCloneStringify(file.data) + : strictJsonStringify(file.data, file.fnName) + await fs.writeFile( + fullpath, + // structured-clone-es output is single-line; only JSON honors `indent`. + file.serialization === 'json' && indent != null + ? JSON.stringify(JSON.parse(text), null, indent) + : text, + 'utf-8', + ) + } + await fs.writeFile( + resolve(outDir, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME), + JSON.stringify(dump.manifest, null, 2), + 'utf-8', + ) + + if (d.spa) { + const base = options.base ?? resolveBasePath(d, 'standalone') + const spaLoader = { + version: 1, + mode: d.spa.loader ?? 'none', + base, + } + await fs.writeFile( + resolve(outDir, 'spa-loader.json'), + JSON.stringify(spaLoader, null, 2), + 'utf-8', + ) + } + + console.log(c.green`[devframe] built "${d.id}" -> ${outDir}`) +} diff --git a/packages/devframe/src/adapters/cli.ts b/packages/devframe/src/adapters/cli.ts new file mode 100644 index 0000000..c3a0bab --- /dev/null +++ b/packages/devframe/src/adapters/cli.ts @@ -0,0 +1,138 @@ +import type { CAC } from 'cac' +import type { App } from 'h3' +import type { DevframeDefinition } from '../types/devframe' +import process from 'node:process' +import cac from 'cac' +import { colors as c } from '../utils/colors' +import { createBuild } from './build' +import { createDevServer, resolveDevServerPort } from './dev' +import { flagKeyToOption, isBooleanFlag, parseCliFlags } from './flags' + +export { defineCliFlags, parseCliFlags } from './flags' +export type { CliFlagsSchema, InferCliFlags } from './flags' + +export interface CreateCliOptions { + /** Default port for `dev` (default: 9999). */ + defaultPort?: number + /** + * Final CAC hook invoked after devframe's built-in subcommands and + * after the definition's `cli.configure`. Use this to add app-level + * flags and commands at the assembly stage. + */ + configureCli?: (cli: CAC) => void + /** + * Called once the dev server is listening. Use this to print a + * startup banner or trigger side-effects that depend on the live URL. + */ + onReady?: (info: { origin: string, port: number, app: App }) => void | Promise +} + +export interface CliHandle { + /** + * Raw CAC instance. Mutate before calling `parse()` for last-mile + * flag or command additions that don't fit `configureCli`. + */ + cli: CAC + parse: (argv?: string[]) => Promise +} + +export function createCli(d: DevframeDefinition, options: CreateCliOptions = {}): CliHandle { + const defaultPort = options.defaultPort ?? d.cli?.port ?? 9999 + const defaultHost = d.cli?.host ?? 'localhost' + const command = d.cli?.command ?? d.id + + const cli = cac(command) + + const devCommand = cli + .command('[...args]', 'Start a local dev server') + .option('--port ', 'Port to listen on') + .option('--host ', 'Host to bind to', { default: defaultHost }) + .option('--open', 'Open the browser on start') + .option('--no-open', 'Do not open the browser') + + // Register typed flags from the definition ahead of `cli.configure` + // so authors can still override or augment via the escape hatch. + if (d.cli?.flags) { + for (const [key, schema] of Object.entries(d.cli.flags)) { + const optionName = flagKeyToOption(key) + const description = (schema as any).description ?? '' + if (isBooleanFlag(schema)) { + devCommand.option(`--${optionName}`, description) + } + else { + devCommand.option(`--${optionName} `, description) + } + } + } + + devCommand.action(async (_args: unknown, rawFlags: CliFlags) => { + const flags = resolveTypedFlags(d, rawFlags) as CliFlags + const host = (flags.host as string | undefined) ?? defaultHost + const port = (flags.port as number | undefined) ?? await resolveDevServerPort(d, { host, defaultPort }) + await createDevServer(d, { + host, + port, + flags, + onReady: options.onReady, + }) + }) + + cli + .command('build', 'Build a self-contained static deploy of the devframe') + .option('--out-dir ', 'Output directory', { default: 'dist-static' }) + .option('--base ', 'URL base', { default: '/' }) + .option('--pretty', 'Pretty-print dump JSON (larger on disk)') + .action(async (flags: { outDir: string, base?: string, pretty?: boolean }) => { + await createBuild(d, { outDir: flags.outDir, base: flags.base, pretty: flags.pretty }) + }) + + cli + .command('mcp', 'Start an MCP server exposing agent-facing tools (stdio) [experimental]') + .action(async () => { + // MCP clients expect JSON-RPC on stdout — route welcome/logging + // noise out of the way. Logs-SDK diagnostics land on stderr by + // default, so nothing extra needed beyond not printing here. + const { createMcpServer } = await import('./mcp') + await createMcpServer(d, { + transport: 'stdio', + // Deliberately go to stderr: stdout is the MCP transport. + onReady: ({ transport }) => { + console.error(`[devframe] "${d.id}" MCP server ready (${transport})`) + }, + }) + }) + + // Definition-level capability hook first, then assembly-level hook. + d.cli?.configure?.(cli) + options.configureCli?.(cli) + + cli.help() + cli.version('0.0.0') + + return { + cli, + async parse(argv = process.argv) { + cli.parse(argv, { run: false }) + await cli.runMatchedCommand() + }, + } +} + +interface CliFlags { + host?: string + port?: number + open?: boolean + [key: string]: unknown +} + +function resolveTypedFlags(d: DevframeDefinition, raw: Record): Record { + if (!d.cli?.flags) + return raw + const { flags, issues } = parseCliFlags(d.cli.flags, raw) + if (issues?.length) { + for (const issue of issues) + console.error(c.red`[devframe] invalid flag — ${issue}`) + process.exit(1) + } + return flags +} diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts new file mode 100644 index 0000000..a72c6a0 --- /dev/null +++ b/packages/devframe/src/adapters/dev.ts @@ -0,0 +1,203 @@ +import type { App } from 'h3' +import type { StartedServer } from '../node/server' +import type { DevframeDefinition, DevframeSetupInfo } from '../types/devframe' +import process from 'node:process' +import { getPort } from 'get-port-please' +import { createApp, eventHandler } from 'h3' +import { resolve } from 'pathe' +import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants' +import { createHostContext } from '../node/context' +import { createH3DevToolsHost } from '../node/host-h3' +import { startHttpAndWs } from '../node/server' +import { open } from '../utils/open' +import { serveStaticHandler } from '../utils/serve-static' +import { normalizeBasePath, resolveBasePath } from './_shared' + +const DEFAULT_PORT = 9999 + +export interface CreateDevServerOptions { + /** Bind host. Default: `def.cli?.host ?? 'localhost'`. */ + host?: string + /** + * Port to listen on. When omitted, falls back to + * {@link resolveDevServerPort}, which respects `def.cli?.port` / + * `portRange` / `random`. + */ + port?: number + /** + * Parsed flag bag forwarded to `setup(ctx, { flags })`. The dev + * server itself only reads `flags.open` from this bag, and only when + * {@link CreateDevServerOptions.openBrowser} is left undefined. + */ + flags?: Record + /** + * Override `def.cli?.distDir`. When neither this option nor + * `def.cli?.distDir` is set, the dev server runs in **bridge mode** — + * only `__connection.json` and the WS endpoint are mounted; the SPA + * is expected to be hosted elsewhere (e.g. by a parent Vite/Nuxt + * dev server via `createVitePlugin({ devMiddleware })`). + */ + distDir?: string + /** + * Override the SPA mount path. Defaults to + * `resolveBasePath(def, 'standalone')` (i.e. `def.basePath` or `/`). + */ + basePath?: string + /** + * h3 app to mount the SPA + connection-meta routes on. When omitted + * a fresh app is created. Pass a pre-configured app to attach custom + * middleware (auth, logging, extra static assets) before devframe's + * own handlers. + */ + app?: App + /** + * Auto-open the browser. When `undefined` the resolution falls + * through to `flags.open` (incl. string path) and finally + * `def.cli?.open`. `false` disables the open regardless of the other + * sources; a string opens that relative path. + */ + openBrowser?: boolean | string + /** + * Called once the WS server is bound. Devframe stays headless + * otherwise — wire this if you want a startup banner. + */ + onReady?: (info: { origin: string, port: number, app: App }) => void | Promise +} + +export interface ResolveDevServerPortOptions { + /** Bind host (passed to `get-port-please` for in-use detection). */ + host?: string + /** Override the preferred port. Default: `def.cli?.port ?? 9999`. */ + defaultPort?: number +} + +/** + * Resolve the listening port for {@link createDevServer}, honoring the + * definition's `cli.port` / `cli.portRange` / `cli.random` settings. + * Exposed separately so authors who run their own argv parsing can + * resolve a port up-front (to print it, log it, etc.) before starting + * the server. + */ +export async function resolveDevServerPort( + def: DevframeDefinition, + options: ResolveDevServerPortOptions = {}, +): Promise { + const host = options.host ?? def.cli?.host ?? 'localhost' + const port = options.defaultPort ?? def.cli?.port ?? DEFAULT_PORT + // Only include optional fields when set — `get-port-please` spreads + // user options over its defaults, so `portRange: undefined` would + // wipe out the internal `[]` and crash on iteration. + const portOptions: Parameters[0] = { port, host } + if (def.cli?.portRange) + portOptions.portRange = def.cli.portRange + if (def.cli?.random) + portOptions.random = def.cli.random + return getPort(portOptions) +} + +/** + * Start a devframe dev server for a {@link DevframeDefinition} — + * h3 + WebSocket RPC + (optionally) the author's SPA mounted at the + * resolved base path. + * + * When `distDir` is omitted (and `def.cli?.distDir` is unset) the + * server runs in **bridge mode**: only `__connection.json` and the WS + * endpoint are mounted, with no SPA mount. The SPA is expected to be + * hosted elsewhere (e.g. by a parent Vite/Nuxt dev server) — see + * `createVitePlugin({ devMiddleware })`. + * + * Returns the underlying {@link StartedServer} handle so callers can + * close it gracefully (SIGINT, hot-reload, test teardown). + * + * Use this directly when integrating devframe into an existing CLI + * framework (commander, yargs, hand-rolled CAC). For the all-in-one + * `dev` / `build` / `mcp` shell, reach for {@link createCli} instead. + */ +export async function createDevServer( + def: DevframeDefinition, + options: CreateDevServerOptions = {}, +): Promise { + const distDir = options.distDir ?? def.cli?.distDir + + const host = options.host ?? def.cli?.host ?? 'localhost' + const port = options.port ?? await resolveDevServerPort(def, { host }) + const flags = options.flags ?? {} + const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, 'standalone') + const app = options.app ?? createApp() + const origin = `http://${host}:${port}` + + const h3Host = createH3DevToolsHost({ + origin, + appName: def.id, + mount: (base, dir) => { + app.use(base, serveStaticHandler(dir)) + }, + }) + + const ctx = await createHostContext({ + cwd: process.cwd(), + mode: 'dev', + host: h3Host, + }) + const setupInfo: DevframeSetupInfo = { flags } + await def.setup(ctx, setupInfo) + + // Connection meta — the SPA fetches this to discover the RPC backend. + // In dev the WS endpoint shares the HTTP port, so the client only needs + // to know it's a websocket backend bound to that same port. The path + // sits at the SPA root (next to index.html) so the deployed SPA can + // discover it via a relative `./__connection.json` fetch. + const connectionMetaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + app.use(connectionMetaPath, eventHandler((event) => { + event.node.res.setHeader('Content-Type', 'application/json') + return event.node.res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + })) + + if (distDir) + app.use(basePath, serveStaticHandler(resolve(distDir))) + + return startHttpAndWs({ + context: ctx, + host, + port, + app, + auth: def.cli?.auth, + onReady: async (info) => { + await options.onReady?.(info) + await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser) + }, + }) +} + +async function maybeOpenBrowser( + def: DevframeDefinition, + flags: Record, + origin: string, + override: boolean | string | undefined, +): Promise { + const flagsOpen = flags.open as boolean | string | undefined + const cliOpen = def.cli?.open + // Explicit override wins; otherwise CLI flag (`--open` / `--no-open` + // / `--open path`); finally the definition default. + const resolved = override ?? flagsOpen ?? cliOpen + if (resolved === undefined || resolved === false) + return + const target = typeof resolved === 'string' + ? resolveOpenTarget(origin, resolved) + : origin + try { + await open(target) + } + catch { + // Failing to launch a browser shouldn't break the dev server. + // The user can navigate manually. + } +} + +function resolveOpenTarget(origin: string, target: string): string { + if (/^https?:/.test(target)) + return target + if (target.startsWith('/')) + return origin.replace(/\/$/, '') + target + return origin.replace(/\/$/, '') + (target ? `/${target}` : '') +} diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts new file mode 100644 index 0000000..17d86b6 --- /dev/null +++ b/packages/devframe/src/adapters/embedded.ts @@ -0,0 +1,20 @@ +import type { DevToolsNodeContext } from '../types/context' +import type { DevframeDefinition } from '../types/devframe' + +export interface CreateEmbeddedOptions { + /** Target context the devframe is registered into. Required. */ + ctx: DevToolsNodeContext +} + +/** + * Register a devframe into an already-running devframe/Kit context at + * runtime. Mirrors what the Vite plugin scan does for devframes passed + * as plugin options, but exposes the same flow to callers that need + * dynamic, post-startup registration. + * + * The host owns the mount path; when a hosted mount is needed the + * effective default follows the hosted rule of `def.basePath ?? '/__/'`. + */ +export async function createEmbedded(d: DevframeDefinition, options: CreateEmbeddedOptions): Promise { + await d.setup(options.ctx) +} diff --git a/packages/devframe/src/adapters/flags.ts b/packages/devframe/src/adapters/flags.ts new file mode 100644 index 0000000..b104219 --- /dev/null +++ b/packages/devframe/src/adapters/flags.ts @@ -0,0 +1,104 @@ +import type { GenericSchema, InferOutput } from 'valibot' +import { safeParse } from 'valibot' + +/** + * Schema map for typed CLI flags. Keys are flag names in camelCase — + * this matches CAC's parsed-flag output ( `--no-open` → `noOpen` ). Each + * value is a valibot schema used to both (a) derive the CAC option type + * when the flag is registered and (b) validate / coerce the parsed + * value before it's forwarded to `setup(ctx, { flags })`. + */ +export type CliFlagsSchema = Record + +/** + * Identity helper that preserves the literal schema-map type — use this + * so `InferCliFlags` resolves to the right object shape. + * + * ```ts + * const appFlags = defineCliFlags({ + * depth: v.pipe(v.number(), v.integer()), + * config: v.optional(v.string()), + * }) + * + * defineDevframe({ + * cli: { flags: appFlags }, + * setup(ctx, info) { + * const flags = info.flags as InferCliFlags + * flags.depth // number + * flags.config // string | undefined + * }, + * }) + * ``` + */ +export function defineCliFlags(flags: T): T { + return flags +} + +/** Extract the parsed-output type from a {@link CliFlagsSchema}. */ +export type InferCliFlags = { + [K in keyof T]: InferOutput +} + +/** + * Best-effort probe of a valibot schema to decide whether the + * corresponding CAC option takes a value. Unwraps `optional` / `nullable` + * / `nullish` / `default` / `pipe` wrappers then matches on the inner + * type's kind. + */ +function getSchemaKind(schema: GenericSchema): string { + let current: any = schema + while (current) { + const kind = current.type + if (kind === 'optional' || kind === 'nullable' || kind === 'nullish' || kind === 'undefined') { + current = current.wrapped ?? current.inner + continue + } + if (kind === 'pipe' && Array.isArray(current.pipe) && current.pipe.length > 0) { + current = current.pipe[0] + continue + } + return kind + } + return 'unknown' +} + +/** Whether the CAC option for this schema should be a boolean flag. */ +export function isBooleanFlag(schema: GenericSchema): boolean { + return getSchemaKind(schema) === 'boolean' +} + +/** Validate and coerce the raw cac-parsed bag against a {@link CliFlagsSchema}. */ +export function parseCliFlags( + schema: CliFlagsSchema, + raw: Record, +): { flags: Record, issues?: string[] } { + const flags: Record = {} + const issues: string[] = [] + for (const [key, fieldSchema] of Object.entries(schema)) { + const result = safeParse(fieldSchema, raw[key]) + if (result.success) { + flags[key] = result.output + } + else { + issues.push(`--${toKebab(key)}: ${result.issues.map(i => i.message).join(', ')}`) + } + } + // Preserve any raw flags that aren't in the schema (e.g. --host, --port, + // or options contributed via cli.configure) so authors keep access to + // them. + for (const [key, value] of Object.entries(raw)) { + if (!(key in schema) && !(key in flags)) { + flags[key] = value + } + } + return issues.length ? { flags, issues } : { flags } +} + +function toKebab(camel: string): string { + return camel.replaceAll(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() +} + +/** Kebab-case a schema key for CAC option registration. */ +export function flagKeyToOption(camel: string): string { + return toKebab(camel) +} diff --git a/packages/devframe/src/adapters/mcp.ts b/packages/devframe/src/adapters/mcp.ts new file mode 100644 index 0000000..6d00185 --- /dev/null +++ b/packages/devframe/src/adapters/mcp.ts @@ -0,0 +1,19 @@ +// Public entry for the devframe MCP adapter. Translates the agent-host +// surface of a DevframeDefinition into an MCP server. +// +// Usage: +// import { createMcpServer } from 'devframe/adapters/mcp' +// await createMcpServer(definition, { transport: 'stdio' }) +// +// Requires `@modelcontextprotocol/sdk` to be installed as a peer +// dependency. Importing this entry without the SDK throws at load time +// with the usual Node module-not-found error. +// +// @experimental The agent-native surface is experimental and may change +// without a major version bump until it stabilizes. + +export { + createMcpServer, + type CreateMcpServerOptions, + type McpServerHandle, +} from '../node/mcp/build-server' diff --git a/packages/devframe/src/adapters/vite.ts b/packages/devframe/src/adapters/vite.ts new file mode 100644 index 0000000..74ba88e --- /dev/null +++ b/packages/devframe/src/adapters/vite.ts @@ -0,0 +1,145 @@ +import type { DevframeDefinition } from '../types/devframe' +import { resolve } from 'pathe' +import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants' +import { logger } from '../node/diagnostics' +import { serveStaticNodeMiddleware } from '../utils/serve-static' +import { resolveBasePath } from './_shared' +import { createDevServer, resolveDevServerPort } from './dev' + +export interface CreateVitePluginOptions { + /** + * Mount base. Defaults to `def.basePath ?? '/__/'` for this hosted + * adapter — the devframe shares the origin with the host Vite app. + * + * Relative spellings like `'./'` (common for base-agnostic Nuxt builds) + * are normalized to absolute paths so they compose with Vite's connect + * router. + */ + base?: string + /** + * Dev-time middleware mode. When set, the host app owns the SPA and + * devframe spins up a separate RPC + WS server on a resolved port, + * registering Vite middleware at `__connection.json` so the + * host-served SPA can discover the WS endpoint. + * + * - `false` (default) — static-mount the SPA at `base` with SPA + * fallback. No RPC server is started. + * - `true` — bridge mode with all defaults (port from + * {@link resolveDevServerPort}, host from `def.cli?.host`). + * - object — bridge mode with explicit overrides. + */ + devMiddleware?: boolean | { + /** Override the bridge port. Default: {@link resolveDevServerPort}. */ + port?: number + /** Override the bridge bind host. Default: `def.cli?.host ?? 'localhost'`. */ + host?: string + /** Flag bag forwarded to `def.setup(ctx, { flags })`. */ + flags?: Record + } +} + +export interface DevframeVitePlugin { + name: string + apply: 'serve' + configureServer: (server: { + middlewares: { use: (path: string, handler: any) => void } + httpServer?: { once: (event: 'close', cb: () => void) => void } | null + }) => void | Promise + closeBundle?: () => void | Promise +} + +/** + * Vite plugin for hosting a devframe inside a Vite dev server. + * + * Two modes, picked via `options.devMiddleware`: + * + * - **static-mount mode** (default) — mounts `def.cli.distDir` at + * `options.base` with SPA fallback enabled. No RPC server is started. + * + * - **bridge mode** (`devMiddleware: true | {…}`) — skips the static + * mount; the host app owns the SPA. Devframe starts a separate + * RPC + WS dev server (via {@link createDevServer} in bridge mode) + * and registers Vite middleware at `__connection.json` so the + * host-served SPA can discover the WS endpoint via + * {@link connectDevframe}. + * + * Use bridge mode when integrating with frameworks that own the SPA + * (Nuxt, Astro, SolidStart, plain Vite apps). For the all-in-one + * `dev` / `build` / `mcp` shell, reach for {@link createCli} instead. + */ +export function createVitePlugin(d: DevframeDefinition, options: CreateVitePluginOptions = {}): DevframeVitePlugin { + const base = normalizeMountBase(options.base ?? resolveBasePath(d, 'hosted')) + + if (!options.devMiddleware) { + const distDir = d.cli?.distDir + return { + name: `devframe:${d.id}`, + apply: 'serve', + configureServer(server) { + if (!distDir) + return + server.middlewares.use(base, serveStaticNodeMiddleware(resolve(distDir))) + }, + } + } + + const mw = options.devMiddleware === true ? {} : options.devMiddleware + let started: Awaited> | undefined + + return { + name: `devframe:${d.id}`, + apply: 'serve', + async configureServer(server) { + // Vite re-invokes `configureServer` on each restart cycle; close + // the prior handle so we don't leak the WS server. Silent catch — + // a stale handle's close failure shouldn't block a fresh start. + await started?.close().catch(() => {}) + started = undefined + + let port: number + try { + port = mw.port ?? await resolveDevServerPort(d, { host: mw.host }) + started = await createDevServer(d, { + host: mw.host, + port, + flags: mw.flags, + openBrowser: false, + }) + } + catch (e) { + logger.DF0033({ id: d.id, reason: String(e) }, { cause: e as Error }).log() + return + } + + const metaPath = `${base}${DEVTOOLS_CONNECTION_META_FILENAME}` + server.middlewares.use(metaPath, (_req: unknown, res: any) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + }) + + server.httpServer?.once('close', () => { + void started?.close().catch(() => {}) + }) + }, + + async closeBundle() { + await started?.close().catch(() => {}) + started = undefined + }, + } +} + +/** + * Make `base` safe for `server.middlewares.use(path, …)`. Vite's connect + * router matches by absolute URL prefix, so relative spellings like + * `'./'` (commonly used for base-agnostic Nuxt builds) need to be + * converted to `/` first. + */ +function normalizeMountBase(base: string): string { + let out = base.replace(/^\.\/?/, '/') + if (!out.startsWith('/')) + out = `/${out}` + if (!out.endsWith('/')) + out = `${out}/` + return out.replace(/\/+/g, '/') +} diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts new file mode 100644 index 0000000..78894c9 --- /dev/null +++ b/packages/devframe/src/client/index.ts @@ -0,0 +1,16 @@ +import { getDevToolsRpcClient } from './rpc' + +export * from './rpc' +export * from './rpc-streaming' + +export const connectDevframe = getDevToolsRpcClient + +let warnedConnectDevtool = false +/** @deprecated Use `connectDevframe`. */ +export function connectDevtool(...args: Parameters): ReturnType { + if (!warnedConnectDevtool) { + warnedConnectDevtool = true + console.warn('[devframe] `connectDevtool` is deprecated; use `connectDevframe` instead.') + } + return getDevToolsRpcClient(...args) +} diff --git a/packages/devframe/src/client/rpc-shared-state.ts b/packages/devframe/src/client/rpc-shared-state.ts new file mode 100644 index 0000000..12e9574 --- /dev/null +++ b/packages/devframe/src/client/rpc-shared-state.ts @@ -0,0 +1,129 @@ +import type { RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' +import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' +import type { DevToolsRpcClient } from './rpc' +import { createSharedState } from 'devframe/utils/shared-state' + +export function createRpcSharedStateClientHost(rpc: DevToolsRpcClient): RpcSharedStateHost { + const sharedState = new Map>() + const initialValues = new Map() + const keyAddedListeners = new Set<(key: string) => void>() + const isStaticBackend = rpc.connectionMeta.backend === 'static' + + function mergeWithInitialValue(key: string, serverState: any): any { + const initial = initialValues.get(key) + if (initial && typeof initial === 'object' && !Array.isArray(initial) + && typeof serverState === 'object' && !Array.isArray(serverState)) { + return { ...initial, ...serverState } + } + return serverState + } + + rpc.client.register({ + name: 'devframe:rpc:client-state:updated', + type: 'event', + handler: (key: string, fullState: any, syncId: string) => { + const state = sharedState.get(key) + if (!state || state.syncIds.has(syncId)) + return + state.mutate(() => mergeWithInitialValue(key, fullState), syncId) + }, + }) + + rpc.client.register({ + name: 'devframe:rpc:client-state:patch', + type: 'event', + handler: (key: string, patches: SharedStatePatch[], syncId: string) => { + const state = sharedState.get(key) + if (!state || state.syncIds.has(syncId)) + return + state.patch(patches, syncId) + }, + }) + + function registerSharedState(key: string, state: SharedState) { + const offs: (() => void)[] = [] + offs.push(state.on('updated', (fullState, patches, syncId) => { + if (isStaticBackend) + return + if (patches) { + rpc.callEvent('devframe:rpc:server-state:patch', key, patches, syncId) + } + else { + rpc.callEvent('devframe:rpc:server-state:set', key, fullState, syncId) + } + })) + + return () => { + for (const off of offs) { + off() + } + } + } + + return { + keys: () => Array.from(sharedState.keys()), + onKeyAdded(fn) { + keyAddedListeners.add(fn) + return () => { + keyAddedListeners.delete(fn) + } + }, + get: async (key: string, options?: RpcSharedStateGetOptions) => { + if (options?.initialValue !== undefined) { + initialValues.set(key, options.initialValue) + } + if (sharedState.has(key)) { + return sharedState.get(key)! + } + + const state = createSharedState({ + initialValue: options?.initialValue as T, + enablePatches: false, + }) + + async function initSharedState() { + if (!isStaticBackend) { + rpc.callEvent('devframe:rpc:server-state:subscribe', key) + } + if (options?.initialValue !== undefined) { + sharedState.set(key, state) + for (const fn of keyAddedListeners) + fn(key) + rpc.call('devframe:rpc:server-state:get', key) + .then((serverState) => { + if (serverState !== undefined) + state.mutate(() => mergeWithInitialValue(key, serverState)) + }) + .catch((error) => { + console.error('Error getting server state', error) + }) + registerSharedState(key, state) + return state + } + else { + const serverValue = await rpc.call('devframe:rpc:server-state:get', key) as T + state.mutate(() => mergeWithInitialValue(key, serverValue)) + sharedState.set(key, state) + for (const fn of keyAddedListeners) + fn(key) + registerSharedState(key, state) + return state + } + } + + return new Promise>((resolve) => { + if (!rpc.isTrusted) { + resolve(state) + rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { + if (isTrusted) { + initSharedState() + } + }) + } + else { + initSharedState().then(resolve) + } + }) + }, + } +} diff --git a/packages/devframe/src/client/rpc-static.ts b/packages/devframe/src/client/rpc-static.ts new file mode 100644 index 0000000..9242c63 --- /dev/null +++ b/packages/devframe/src/client/rpc-static.ts @@ -0,0 +1,33 @@ +import type { DevToolsRpcClientMode } from './rpc' +import { DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME } from 'devframe/constants' +import { createStaticRpcCaller } from './static-rpc' + +export interface CreateStaticRpcClientModeOptions { + fetchJsonFromBases: (path: string) => Promise +} + +export async function createStaticRpcClientMode( + options: CreateStaticRpcClientModeOptions, +): Promise { + const manifest = await options.fetchJsonFromBases(DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME) + const staticCaller = createStaticRpcCaller(manifest, options.fetchJsonFromBases) + + return { + isTrusted: true, + requestTrust: async () => true, + requestTrustWithToken: async () => true, + ensureTrusted: async () => true, + call: (...args: any): any => staticCaller.call( + args[0] as string, + args.slice(1), + ), + callEvent: (...args: any): any => staticCaller.callEvent( + args[0] as string, + args.slice(1), + ), + callOptional: (...args: any): any => staticCaller.callOptional( + args[0] as string, + args.slice(1), + ), + } +} diff --git a/packages/devframe/src/client/rpc-streaming.ts b/packages/devframe/src/client/rpc-streaming.ts new file mode 100644 index 0000000..ebb6c55 --- /dev/null +++ b/packages/devframe/src/client/rpc-streaming.ts @@ -0,0 +1,192 @@ +import type { StreamErrorPayload, StreamReader, StreamSink } from 'devframe/utils/streaming-channel' +import type { DevToolsRpcClient } from './rpc' +import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-channel' + +const STREAM_KEY_SEPARATOR = '\x1F' + +function streamKey(channel: string, id: string): string { + return `${channel}${STREAM_KEY_SEPARATOR}${id}` +} + +export interface StreamingSubscribeOptions { + /** Maximum buffered chunks before the oldest is dropped. Default 256. */ + highWaterMark?: number +} + +export interface RpcStreamingClientHost { + /** + * Subscribe to a server-side stream by channel + id. Returns a reader + * that's both an `AsyncIterable` (`for await`) and exposes + * `readable: ReadableStream` for `pipeTo`-style consumption. + */ + subscribe: ( + channel: string, + id: string, + options?: StreamingSubscribeOptions, + ) => StreamReader + /** + * Open the client side of a client-to-server upload. The id is + * typically obtained from a prior action call that ran + * `channel.openInbound()` on the server. Returns a `StreamSink` + * that mirrors the server-side producer surface (write / close / + * error / writable / signal). + * + * The sink's `signal` aborts when the server cancels the upload. + */ + upload: (channel: string, id: string) => StreamSink +} + +/** + * Client-side streaming host. Mirrors `createRpcSharedStateClientHost`: + * registers the two `:chunk` / `:end` event handlers once, then per-stream + * state lives in a `Map`. + */ +export function createRpcStreamingClientHost(rpc: DevToolsRpcClient): RpcStreamingClientHost { + const readers = new Map>() + const uploads = new Map>() + + rpc.client.register({ + name: 'devframe:streaming:chunk', + type: 'event', + handler(channel: string, id: string, seq: number, chunk: any) { + const reader = readers.get(streamKey(channel, id)) + reader?._push(seq, chunk) + }, + }) + + rpc.client.register({ + name: 'devframe:streaming:end', + type: 'event', + handler(channel: string, id: string, error?: StreamErrorPayload) { + const key = streamKey(channel, id) + const reader = readers.get(key) + if (!reader) + return + reader._end(error) + readers.delete(key) + }, + }) + + rpc.client.register({ + name: 'devframe:streaming:upload-cancel', + type: 'event', + handler(channel: string, id: string) { + const key = streamKey(channel, id) + const sink = uploads.get(key) + if (!sink) + return + // Server told us to stop — flip the sink's signal so producers + // observing it can short-circuit. We don't error() here so the + // local closer-of-record decides terminal state. + sink.abort('server cancelled upload') + uploads.delete(key) + }, + }) + + // Re-subscribe on reconnect — the server may have rebooted (lost state) + // OR the WS dropped briefly (state intact). Either way, sending `subscribe` + // with `afterSeq: lastSeenSeq` is the right thing: the server replays + // missed chunks if it has them, otherwise starts fresh. + rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { + if (!isTrusted) + return + for (const [key, reader] of readers) { + if (reader.cancelled || reader.done) + continue + const sepIdx = key.indexOf(STREAM_KEY_SEPARATOR) + if (sepIdx < 0) + continue + const channel = key.slice(0, sepIdx) + const id = key.slice(sepIdx + 1) + rpc.callEvent( + 'devframe:streaming:subscribe', + channel, + id, + { afterSeq: reader.lastSeenSeq }, + ) + } + }) + + function subscribe( + channel: string, + id: string, + options: StreamingSubscribeOptions = {}, + ): StreamReader { + const key = streamKey(channel, id) + const existing = readers.get(key) + if (existing) + return existing as StreamReader + + const reader = createStreamReader({ + id, + highWaterMark: options.highWaterMark, + onOverflow(dropped) { + console.warn( + `[devframe] DF0029: Stream "${channel}#${id}" dropped ${dropped} chunk(s) ` + + `after exceeding the client high-water mark.`, + ) + }, + onCancel() { + rpc.callEvent('devframe:streaming:cancel', channel, id) + readers.delete(key) + }, + }) + + readers.set(key, reader) + + // Subscribe immediately if already trusted; otherwise wait for trust. + // Mirrors `client/rpc-shared-state.ts` behavior. + if (rpc.isTrusted) { + rpc.callEvent('devframe:streaming:subscribe', channel, id, { + afterSeq: 0, + }) + } + else { + const off = rpc.events.on('rpc:is-trusted:updated', (trusted) => { + if (trusted) { + off() + if (readers.has(key) && !reader.cancelled && !reader.done) { + rpc.callEvent('devframe:streaming:subscribe', channel, id, { + afterSeq: reader.lastSeenSeq, + }) + } + } + }) + } + + return reader + } + + function upload(channel: string, id: string): StreamSink { + const key = streamKey(channel, id) + const existing = uploads.get(key) + if (existing) + return existing as StreamSink + + const sink = createStreamSink({ id }) + + sink.events.on('chunk', (seq, chunk) => { + rpc.callEvent( + 'devframe:streaming:upload-chunk', + channel, + id, + seq, + chunk, + ) + }) + sink.events.on('end', (error) => { + rpc.callEvent( + 'devframe:streaming:upload-end', + channel, + id, + error, + ) + uploads.delete(key) + }) + + uploads.set(key, sink) + return sink + } + + return { subscribe, upload } +} diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts new file mode 100644 index 0000000..4fab985 --- /dev/null +++ b/packages/devframe/src/client/rpc-ws.ts @@ -0,0 +1,152 @@ +import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter } from 'devframe/types' +import type { DevToolsClientRpcHost, DevToolsRpcClientMode, DevToolsRpcClientOptions, RpcClientEvents } from './rpc' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { promiseWithResolver } from 'devframe/utils/promise' +import { parseUA } from 'ua-parser-modern' + +export interface CreateWsRpcClientModeOptions { + authToken: string + connectionMeta: ConnectionMeta + events: EventEmitter + clientRpc: DevToolsClientRpcHost + rpcOptions?: DevToolsRpcClientOptions['rpcOptions'] + wsOptions?: DevToolsRpcClientOptions['wsOptions'] +} + +function isNumeric(str: string | number | undefined) { + if (str == null) + return false + return `${+str}` === `${str}` +} + +export function createWsRpcClientMode( + options: CreateWsRpcClientModeOptions, +): DevToolsRpcClientMode { + const { + authToken, + connectionMeta, + events, + clientRpc, + rpcOptions = {}, + wsOptions = {}, + } = options + + let isTrusted = false + const trustedPromise = promiseWithResolver() + const url = isNumeric(connectionMeta.websocket) + ? `${location.protocol.replace('http', 'ws')}//${location.hostname}:${connectionMeta.websocket}` + : connectionMeta.websocket as string + + // Build a minimal `defs` map from the connection meta so the per-call + // wire serializer dispatches outgoing requests with the correct + // encoding (JSON for `jsonSerializable: true` methods; structured- + // clone for the rest). + const definitions = new Map() + for (const name of connectionMeta.jsonSerializableMethods ?? []) + definitions.set(name, { jsonSerializable: true }) + + const serverRpc = createRpcClient( + clientRpc.functions, + { + channel: createWsRpcChannel({ + url, + authToken, + definitions, + ...wsOptions, + }), + rpcOptions, + }, + ) + + // Handle server-initiated auth revocation + clientRpc.register({ + name: 'devframe:auth:revoked', + type: 'event', + handler: () => { + isTrusted = false + events.emit('rpc:is-trusted:updated', false) + }, + }) + + let currentAuthToken = authToken + + async function requestTrustWithToken(token: string) { + currentAuthToken = token + + const info = parseUA(navigator.userAgent) + const ua = [ + info.browser.name, + info.browser.version, + '|', + info.os.name, + info.os.version, + info.device.type, + ].filter(i => i).join(' ') + + const result = await serverRpc.$call('vite:anonymous:auth', { + authToken: token, + ua, + origin: location.origin, + }) + + isTrusted = result.isTrusted + trustedPromise.resolve(isTrusted) + events.emit('rpc:is-trusted:updated', isTrusted) + return result.isTrusted + } + + async function requestTrust() { + if (isTrusted) + return true + return requestTrustWithToken(currentAuthToken) + } + + async function ensureTrusted(timeout = 60_000): Promise { + if (isTrusted) + trustedPromise.resolve(true) + + if (timeout <= 0) + return trustedPromise.promise + + let clear = () => {} + await Promise.race([ + trustedPromise.promise.then(clear), + new Promise((resolve, reject) => { + const id = setTimeout(() => { + reject(new Error('[Vite DevTools] Timeout waiting for rpc to be trusted')) + }, timeout) + clear = () => clearTimeout(id) + }), + ]) + + return isTrusted + } + + return { + get isTrusted() { + return isTrusted + }, + requestTrust, + requestTrustWithToken, + ensureTrusted, + call: (...args: any): any => { + return serverRpc.$call( + // @ts-expect-error casting + ...args, + ) + }, + callEvent: (...args: any): any => { + return serverRpc.$callEvent( + // @ts-expect-error casting + ...args, + ) + }, + callOptional: (...args: any): any => { + return serverRpc.$callOptional( + // @ts-expect-error casting + ...args, + ) + }, + } +} diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts new file mode 100644 index 0000000..6eade6e --- /dev/null +++ b/packages/devframe/src/client/rpc.ts @@ -0,0 +1,328 @@ +import type { BirpcOptions, BirpcReturn } from 'birpc' +import type { RpcCacheOptions, RpcFunctionsCollector } from 'devframe/rpc' +import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' +import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter, RpcSharedStateHost } from 'devframe/types' +import type { RpcStreamingClientHost } from './rpc-streaming' +import { + DEVTOOLS_CONNECTION_META_FILENAME, +} from 'devframe/constants' +import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' +import { createEventEmitter } from 'devframe/utils/events' +import { humanId } from 'devframe/utils/human-id' +import { createRpcSharedStateClientHost } from './rpc-shared-state' +import { createStaticRpcClientMode } from './rpc-static' +import { createRpcStreamingClientHost } from './rpc-streaming' +import { createWsRpcClientMode } from './rpc-ws' + +export interface DevToolsRpcContext { + /** + * The RPC client to interact with the server + */ + readonly rpc: DevToolsRpcClient +} + +export type DevToolsClientRpcHost = RpcFunctionsCollector + +export interface RpcClientEvents { + 'rpc:is-trusted:updated': (isTrusted: boolean) => void +} + +const CONNECTION_META_KEY = '__VITE_DEVTOOLS_CONNECTION_META__' +const CONNECTION_AUTH_TOKEN_KEY = '__VITE_DEVTOOLS_CONNECTION_AUTH_TOKEN__' + +export interface DevToolsRpcClientOptions { + connectionMeta?: ConnectionMeta + baseURL?: string | string[] + /** + * The auth token to use for the client + */ + authToken?: string + wsOptions?: Partial + rpcOptions?: Partial> + cacheOptions?: boolean | Partial +} + +export type DevToolsRpcClientCall = BirpcReturn['$call'] +export type DevToolsRpcClientCallEvent = BirpcReturn['$callEvent'] +export type DevToolsRpcClientCallOptional = BirpcReturn['$callOptional'] + +export interface DevToolsRpcClient { + /** + * The events of the client + */ + events: EventEmitter + + /** + * Whether the client is trusted + */ + readonly isTrusted: boolean | null + /** + * The connection meta + */ + readonly connectionMeta: ConnectionMeta + /** + * Return a promise that resolves when the client is trusted + * + * Rejects with an error if the timeout is reached + * + * @param timeout - The timeout in milliseconds, default to 60 seconds + */ + ensureTrusted: (timeout?: number) => Promise + + /** + * Request trust from the server + */ + requestTrust: () => Promise + + /** + * Request trust from the server using a specific auth token. + * Updates the stored token and re-requests trust without reloading the page. + */ + requestTrustWithToken: (token: string) => Promise + + /** + * Call a RPC function on the server + */ + call: DevToolsRpcClientCall + /** + * Call a RPC event on the server, and does not expect a response + */ + callEvent: DevToolsRpcClientCallEvent + /** + * Call a RPC optional function on the server + */ + callOptional: DevToolsRpcClientCallOptional + /** + * The client RPC host + */ + client: DevToolsClientRpcHost + + /** + * The shared state host + */ + sharedState: RpcSharedStateHost + /** + * The streaming channel host. Subscribe to a server-side stream by + * channel + id; the returned reader is both `AsyncIterable` and + * exposes `.readable: ReadableStream` for `pipeTo` consumption. + */ + streaming: RpcStreamingClientHost + /** + * The RPC cache manager + */ + cacheManager: RpcCacheManager +} + +export interface DevToolsRpcClientMode { + readonly isTrusted: boolean + ensureTrusted: DevToolsRpcClient['ensureTrusted'] + requestTrust: DevToolsRpcClient['requestTrust'] + requestTrustWithToken: DevToolsRpcClient['requestTrustWithToken'] + call: DevToolsRpcClient['call'] + callEvent: DevToolsRpcClient['callEvent'] + callOptional: DevToolsRpcClient['callOptional'] +} + +function getConnectionAuthTokenFromWindows(userAuthToken?: string): string { + const getters = [ + () => userAuthToken, + () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY), + () => (window as any)?.[CONNECTION_AUTH_TOKEN_KEY], + () => (globalThis as any)?.[CONNECTION_AUTH_TOKEN_KEY], + () => (parent.window as any)?.[CONNECTION_AUTH_TOKEN_KEY], + ] + + let value: string | undefined + + for (const getter of getters) { + try { + value = getter() + if (value) + break + } + catch {} + } + + if (!value) + value = humanId() + + localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, value) + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = value + return value +} + +function findConnectionMetaFromWindows(): ConnectionMeta | undefined { + const getters = [ + () => (window as any)?.[CONNECTION_META_KEY], + () => (globalThis as any)?.[CONNECTION_META_KEY], + () => (parent.window as any)?.[CONNECTION_META_KEY], + ] + + for (const getter of getters) { + try { + const value = getter() + if (value) + return value + } + catch {} + } +} + +export async function getDevToolsRpcClient( + options: DevToolsRpcClientOptions = {}, +): Promise { + // Default to a relative base — the SPA owns its mount path at runtime, + // so the connection meta and dump shards live alongside `index.html`. + // Embedded surfaces that run inside a host page (e.g. the Vite DevTools + // webcomponent inject) must pass an explicit `baseURL` because their + // `document.baseURI` points at the host app, not the devtool's mount. + const { + baseURL = './', + rpcOptions = {}, + cacheOptions = false, + } = options + const events = createEventEmitter() + const bases = Array.isArray(baseURL) ? baseURL : [baseURL] + let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows() + let resolvedBaseURL = bases[0] ?? './' + + function normalizeBase(base: string): string { + return base.endsWith('/') ? base : `${base}/` + } + + function resolveBasePath(base: string, path: string): string { + if (/^https?:\/\//.test(path)) + return path + if (path.startsWith('/')) + return path + return `${normalizeBase(base)}${path}` + } + + if (!connectionMeta) { + const errors: Error[] = [] + for (const base of bases) { + try { + connectionMeta = await fetch(resolveBasePath(base, DEVTOOLS_CONNECTION_META_FILENAME)) + .then(r => r.json()) as ConnectionMeta + resolvedBaseURL = base + ;(globalThis as any)[CONNECTION_META_KEY] = connectionMeta + break + } + catch (e) { + errors.push(e as Error) + } + } + if (!connectionMeta) { + throw new Error(`Failed to get connection meta from ${bases.join(', ')}`, { + cause: errors, + }) + } + } + + const cacheManager = new RpcCacheManager({ functions: [], ...(typeof options.cacheOptions === 'object' ? options.cacheOptions : {}) }) + const context: DevToolsRpcContext = { + rpc: undefined!, + } + const authToken = getConnectionAuthTokenFromWindows(options.authToken) + const clientRpc: DevToolsClientRpcHost = new RpcFunctionsCollectorBase(context) + + async function fetchJsonFromBases(path: string): Promise { + const candidates = [ + resolvedBaseURL, + ...bases.filter(base => base !== resolvedBaseURL), + ].filter(x => x != null) + + const errors: Error[] = [] + for (const base of candidates) { + try { + return await fetch(resolveBasePath(base, path)).then((r) => { + if (!r.ok) { + throw new Error(`Failed to fetch ${path} from ${base}: ${r.status}`) + } + return r.json() + }) + } + catch (error) { + errors.push(error as Error) + } + } + + throw new Error(`Failed to load ${path} from ${candidates.join(', ')}`, { + cause: errors, + }) + } + + const mode = connectionMeta.backend === 'static' + ? await createStaticRpcClientMode({ + fetchJsonFromBases, + }) + : createWsRpcClientMode({ + authToken, + connectionMeta, + events, + clientRpc, + rpcOptions: { + ...rpcOptions, + async onRequest(req, next, resolve) { + await rpcOptions.onRequest?.call(this, req, next, resolve) + if (cacheOptions && cacheManager?.validate(req.m)) { + const cached = cacheManager.cached(req.m, req.a) + if (cached) { + return resolve(cached) + } + else { + const res = await next(req) + cacheManager?.apply(req, res) + } + } + else { + await next(req) + } + }, + }, + wsOptions: options.wsOptions, + }) + + const rpc: DevToolsRpcClient = { + events, + get isTrusted() { + return mode.isTrusted + }, + connectionMeta, + ensureTrusted: mode.ensureTrusted, + requestTrust: mode.requestTrust, + requestTrustWithToken: async (token: string) => { + // Update stored token for future reconnections + localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token) + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token + return mode.requestTrustWithToken(token) + }, + call: mode.call, + callEvent: mode.callEvent, + callOptional: mode.callOptional, + client: clientRpc, + sharedState: undefined!, + streaming: undefined!, + cacheManager, + } + + rpc.sharedState = createRpcSharedStateClientHost(rpc) + rpc.streaming = createRpcStreamingClientHost(rpc) + + // @ts-expect-error assign to readonly property + context.rpc = rpc + void mode.requestTrust() + + // Listen for auth updates from other tabs (e.g., auth URL page) + try { + const bc = new BroadcastChannel('vite-devtools-auth') + bc.onmessage = (event) => { + if (event.data?.type === 'auth-update' && event.data.authToken) { + rpc.requestTrustWithToken(event.data.authToken) + } + } + } + catch {} + + return rpc +} diff --git a/packages/devframe/src/client/static-rpc.test.ts b/packages/devframe/src/client/static-rpc.test.ts new file mode 100644 index 0000000..7008268 --- /dev/null +++ b/packages/devframe/src/client/static-rpc.test.ts @@ -0,0 +1,173 @@ +import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' +import { hash } from 'devframe/utils/hash' +import { structuredCloneStringify } from 'devframe/utils/structured-clone' +import { describe, expect, it } from 'vitest' +import { createStaticRpcCaller } from './static-rpc' + +const DEMO_STATIC_VERSION_PATH = `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~version.static.json` +const DEMO_QUERY_BASE_PATH = `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~get-item` +const DEMO_QUERY_RECORDS_PATH = `${DEMO_QUERY_BASE_PATH}.record` +const DEMO_QUERY_FALLBACK_PATH = `${DEMO_QUERY_BASE_PATH}.fallback.json` + +describe('createStaticRpcCaller', () => { + it('loads static rpc shards lazily and caches by file path', async () => { + const calls: string[] = [] + const caller = createStaticRpcCaller( + { + 'demo:version': { + type: 'static', + path: DEMO_STATIC_VERSION_PATH, + }, + }, + async (path) => { + calls.push(path) + return { output: '1.0.0' } + }, + ) + + await expect(caller.call('demo:version', [])).resolves.toBe('1.0.0') + await expect(caller.call('demo:version', [])).resolves.toBe('1.0.0') + expect(calls).toEqual([DEMO_STATIC_VERSION_PATH]) + }) + + it('resolves query records, supports fallback, and replays dumped errors', async () => { + const caller = createStaticRpcCaller( + { + 'demo:get-item': { + type: 'query', + records: { + [hash(['a'])]: `${DEMO_QUERY_RECORDS_PATH}.ok.json`, + [hash(['boom'])]: `${DEMO_QUERY_RECORDS_PATH}.error.json`, + }, + fallback: DEMO_QUERY_FALLBACK_PATH, + }, + }, + async (path) => { + if (path.endsWith('.ok.json')) { + return { + inputs: ['a'], + output: { id: 'a' }, + } + } + if (path.endsWith('.error.json')) { + return { + inputs: ['boom'], + error: { + name: 'TypeError', + message: 'boom', + }, + } + } + if (path.endsWith('.fallback.json')) { + return { + inputs: [], + output: null, + } + } + throw new Error(`Unexpected path: ${path}`) + }, + ) + + await expect(caller.call('demo:get-item', ['a'])).resolves.toEqual({ id: 'a' }) + await expect(caller.call('demo:get-item', ['missing'])).resolves.toBeNull() + await expect(caller.call('demo:get-item', ['boom'])).rejects.toThrow('boom') + }) + + it('keeps call strict while optional/event calls are soft for missing methods', async () => { + const caller = createStaticRpcCaller({}, async () => { + throw new Error('Should not fetch') + }) + + await expect(caller.callOptional('demo:missing', [])).resolves.toBeUndefined() + await expect(caller.callEvent('demo:missing', [])).resolves.toBeUndefined() + await expect(caller.call('demo:missing', [])).rejects.toThrow('[devtools-rpc] Function "demo:missing" not found in dump store') + }) + + it('treats callEvent as no-op in static mode even for known methods', async () => { + const caller = createStaticRpcCaller( + { + 'demo:version': { + type: 'static', + path: DEMO_STATIC_VERSION_PATH, + }, + }, + async () => { + throw new Error('Should not fetch') + }, + ) + + await expect(caller.callEvent('demo:version', [])).resolves.toBeUndefined() + }) + + it('supports legacy inline manifest values for backward compatibility', async () => { + const caller = createStaticRpcCaller( + { + 'demo:legacy': { ok: true }, + }, + async () => { + throw new Error('Should not fetch') + }, + ) + + await expect(caller.call('demo:legacy', [])).resolves.toEqual({ ok: true }) + }) + + it('revives structured-clone-tagged static entries (preserves Map)', async () => { + const caller = createStaticRpcCaller( + { + 'demo:graph': { + type: 'static', + path: `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~graph.static.json`, + serialization: 'structured-clone', + }, + }, + async () => { + // What a server would have written: SC-stringified, then read + // back via fetch.json() (i.e. JSON.parse of the SC text). + const payload = { output: new Map([['a', 1], ['b', 2]]) } + return JSON.parse(structuredCloneStringify(payload)) + }, + ) + + const result = await caller.call('demo:graph', []) as Map + expect(result).toBeInstanceOf(Map) + expect(result.get('a')).toBe(1) + expect(result.get('b')).toBe(2) + }) + + it('revives structured-clone-tagged query records (preserves Set)', async () => { + const recordPath = `${DEMO_QUERY_BASE_PATH}.record.${hash(['k'])}.json` + const caller = createStaticRpcCaller( + { + 'demo:query-set': { + type: 'query', + serialization: 'structured-clone', + records: { [hash(['k'])]: recordPath }, + }, + }, + async () => { + const payload = { inputs: ['k'], output: new Set(['x', 'y']) } + return JSON.parse(structuredCloneStringify(payload)) + }, + ) + + const result = await caller.call('demo:query-set', ['k']) as Set + expect(result).toBeInstanceOf(Set) + expect(result.has('x')).toBe(true) + }) + + it('treats untagged manifest entries as JSON (back-compat)', async () => { + const caller = createStaticRpcCaller( + { + 'demo:legacy-static': { + type: 'static', + path: `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~legacy.static.json`, + // no `serialization` field — must default to JSON parsing + }, + }, + async () => ({ output: { items: [1, 2, 3] } }), + ) + + await expect(caller.call('demo:legacy-static', [])).resolves.toEqual({ items: [1, 2, 3] }) + }) +}) diff --git a/packages/devframe/src/client/static-rpc.ts b/packages/devframe/src/client/static-rpc.ts new file mode 100644 index 0000000..757a050 --- /dev/null +++ b/packages/devframe/src/client/static-rpc.ts @@ -0,0 +1,161 @@ +import { hash } from '../utils/hash' +import { structuredCloneDeserialize } from '../utils/structured-clone' + +export type StaticRpcSerialization = 'json' | 'structured-clone' + +export interface StaticRpcManifestStaticEntry { + type: 'static' + path: string + /** Encoder used when this entry's file was written. Default: `'json'`. */ + serialization?: StaticRpcSerialization +} + +export interface StaticRpcManifestQueryEntry { + type: 'query' + records: Record + fallback?: string + /** Encoder used when each record/fallback file was written. Default: `'json'`. */ + serialization?: StaticRpcSerialization +} + +export type StaticRpcManifestEntry + = | StaticRpcManifestStaticEntry + | StaticRpcManifestQueryEntry + | any + +export type StaticRpcManifest = Record + +export interface StaticRpcRecord { + inputs?: any[] + output?: any + error?: { + message: string + name: string + } +} + +function isStaticEntry(value: unknown): value is StaticRpcManifestStaticEntry { + return typeof value === 'object' + && value !== null + && (value as any).type === 'static' + && typeof (value as any).path === 'string' +} + +function isQueryEntry(value: unknown): value is StaticRpcManifestQueryEntry { + return typeof value === 'object' + && value !== null + && (value as any).type === 'query' + && typeof (value as any).records === 'object' + && (value as any).records !== null +} + +function isRecord(value: unknown): value is StaticRpcRecord { + return typeof value === 'object' + && value !== null + && ('output' in (value as any) || 'error' in (value as any)) +} + +function resolveRecordOutput(record: StaticRpcRecord): any { + if (record.error) { + const error = new Error(record.error.message) + error.name = record.error.name + throw error + } + return record.output +} + +export function createStaticRpcCaller( + manifest: StaticRpcManifest, + fetchJson: (path: string) => Promise, +) { + const staticCache = new Map>() + const queryRecordCache = new Map>() + + function reviveIfStructuredClone(value: unknown, serialization: StaticRpcSerialization | undefined): any { + if (serialization === 'structured-clone') + return structuredCloneDeserialize(value as any) + return value + } + + async function loadStatic(entry: StaticRpcManifestStaticEntry): Promise { + if (!staticCache.has(entry.path)) { + staticCache.set( + entry.path, + fetchJson(entry.path).then(raw => reviveIfStructuredClone(raw, entry.serialization)), + ) + } + const data = await staticCache.get(entry.path)! + if (isRecord(data)) { + return resolveRecordOutput(data) + } + return data + } + + async function loadQueryRecord( + path: string, + serialization: StaticRpcSerialization | undefined, + ): Promise { + if (!queryRecordCache.has(path)) { + queryRecordCache.set( + path, + fetchJson(path).then(raw => reviveIfStructuredClone(raw, serialization)), + ) + } + return await queryRecordCache.get(path)! + } + + async function call(functionName: string, args: any[]) { + if (!(functionName in manifest)) { + throw new Error(`[devtools-rpc] Function "${functionName}" not found in dump store`) + } + + const entry = manifest[functionName] + if (isStaticEntry(entry)) { + if (args.length > 0) { + throw new Error( + `[devtools-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, + ) + } + return await loadStatic(entry) + } + + if (isQueryEntry(entry)) { + const argsHash = hash(args) + const recordPath = entry.records[argsHash] + + if (recordPath) { + const record = await loadQueryRecord(recordPath, entry.serialization) + return resolveRecordOutput(record) + } + + if (entry.fallback) { + const fallback = await loadQueryRecord(entry.fallback, entry.serialization) + return resolveRecordOutput(fallback) + } + + throw new Error( + `[devtools-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, + ) + } + + if (args.length === 0) { + return entry + } + + throw new Error( + `[devtools-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, + ) + } + + return { + call: async (functionName: string, args: any[]) => await call(functionName, args), + callOptional: async (functionName: string, args: any[]) => { + if (!(functionName in manifest)) + return undefined + return await call(functionName, args) + }, + callEvent: async (_functionName: string, _args: any[]) => { + return undefined + }, + } +} diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts new file mode 100644 index 0000000..c04f0b3 --- /dev/null +++ b/packages/devframe/src/constants.ts @@ -0,0 +1,17 @@ +// DevTools runtime routes and static output conventions. +export const DEVTOOLS_MOUNT_PATH = '/__devtools/' +export const DEVTOOLS_MOUNT_PATH_NO_TRAILING_SLASH = '/__devtools' +export const DEVTOOLS_DIRNAME = '__devtools' + +export const DEVTOOLS_CONNECTION_META_FILENAME = '__connection.json' +export const DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME = '__rpc-dump/index.json' +export const DEVTOOLS_DOCK_IMPORTS_FILENAME = '__client-imports.js' +export const DEVTOOLS_DOCK_IMPORTS_VIRTUAL_ID = '/__devtools-client-imports.js' +export const DEVTOOLS_RPC_DUMP_DIRNAME = '__rpc-dump' + +/** + * URL fragment / query parameter name carrying the remote dock + * connection descriptor (defined as `RemoteConnectionInfo` in + * `@vitejs/devtools-kit`) injected into remote-UI iframe dock URLs. + */ +export const REMOTE_CONNECTION_KEY = 'vite-devtools-kit-connection' diff --git a/packages/devframe/src/define.ts b/packages/devframe/src/define.ts new file mode 100644 index 0000000..64af131 --- /dev/null +++ b/packages/devframe/src/define.ts @@ -0,0 +1,4 @@ +import type { DevToolsNodeContext } from 'devframe/types' +import { createDefineWrapperWithContext } from 'devframe/rpc' + +export const defineRpcFunction = createDefineWrapperWithContext() diff --git a/packages/devframe/src/index.ts b/packages/devframe/src/index.ts new file mode 100644 index 0000000..02178ed --- /dev/null +++ b/packages/devframe/src/index.ts @@ -0,0 +1,3 @@ +// Public API. The full defineDevframe + adapter surface lands in later commits. +export * from './define' +export type * from './types' diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts new file mode 100644 index 0000000..d0dac66 --- /dev/null +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -0,0 +1,272 @@ +import type { RpcFunctionDefinitionAnyWithContext } from '../../rpc/types' +import type { DevToolsNodeContext } from '../../types/context' +import { describe, expect, it, vi } from 'vitest' +import { DevToolsAgentHost } from '../host-agent' +import { RpcFunctionsHost } from '../host-functions' + +function createContext(): DevToolsNodeContext { + const ctx = {} as DevToolsNodeContext + ctx.rpc = new RpcFunctionsHost(ctx) + ctx.agent = new DevToolsAgentHost(ctx) + return ctx +} + +function rpcDef(def: RpcFunctionDefinitionAnyWithContext): RpcFunctionDefinitionAnyWithContext { + return def +} + +describe('devToolsAgentHost', () => { + describe('registerTool()', () => { + it('stores a tool and exposes it via list()', () => { + const ctx = createContext() + const handler = vi.fn(async () => 'result') + ctx.agent.registerTool({ + id: 'my-tool', + description: 'Does a thing.', + handler, + }) + + const tools = ctx.agent.list().tools + expect(tools).toHaveLength(1) + expect(tools[0]).toMatchObject({ + id: 'my-tool', + kind: 'tool', + title: 'my-tool', + description: 'Does a thing.', + safety: 'action', + }) + }) + + it('emits agent:tool:registered and agent:manifest:changed', () => { + const ctx = createContext() + const toolHandler = vi.fn() + const manifestHandler = vi.fn() + ctx.agent.events.on('agent:tool:registered', toolHandler) + ctx.agent.events.on('agent:manifest:changed', manifestHandler) + + ctx.agent.registerTool({ + id: 'my-tool', + description: 'Does a thing.', + handler: async () => 'ok', + }) + + expect(toolHandler).toHaveBeenCalledOnce() + expect(manifestHandler).toHaveBeenCalledOnce() + }) + + it('throws DF0014 on empty description', () => { + const ctx = createContext() + expect(() => ctx.agent.registerTool({ + id: 'bad-tool', + description: '', + handler: async () => {}, + })).toThrow(/bad-tool/) + }) + + it('throws DF0015 on duplicate id', () => { + const ctx = createContext() + ctx.agent.registerTool({ + id: 'dup', + description: 'First.', + handler: async () => {}, + }) + expect(() => ctx.agent.registerTool({ + id: 'dup', + description: 'Second.', + handler: async () => {}, + })).toThrow(/already registered/) + }) + + it('throws DF0015 when colliding with an agent-exposed RPC', () => { + const ctx = createContext() + ctx.rpc.register(rpcDef({ + name: 'shared-id', + type: 'query', + jsonSerializable: true, + agent: { description: 'An RPC' }, + setup: () => ({ handler: async () => 'rpc' }), + })) + + expect(() => ctx.agent.registerTool({ + id: 'shared-id', + description: 'Tool', + handler: async () => {}, + })).toThrow(/already registered/) + }) + + it('unregister removes the tool and emits events', () => { + const ctx = createContext() + const unregisterHandler = vi.fn() + ctx.agent.events.on('agent:tool:unregistered', unregisterHandler) + + const handle = ctx.agent.registerTool({ + id: 'ephemeral', + description: 'Goes away.', + handler: async () => {}, + }) + handle.unregister() + + expect(ctx.agent.list().tools).toHaveLength(0) + expect(unregisterHandler).toHaveBeenCalledWith('ephemeral') + }) + }) + + describe('list() RPC auto-discovery', () => { + it('surfaces RPC functions flagged with agent as tools', () => { + const ctx = createContext() + ctx.rpc.register(rpcDef({ + name: 'exposed-rpc', + type: 'query', + jsonSerializable: true, + agent: { + description: 'An exposed RPC.', + title: 'Exposed', + }, + setup: () => ({ handler: async () => 42 }), + })) + + const tools = ctx.agent.list().tools + expect(tools).toHaveLength(1) + expect(tools[0]).toMatchObject({ + id: 'exposed-rpc', + kind: 'rpc', + title: 'Exposed', + description: 'An exposed RPC.', + safety: 'read', + rpcName: 'exposed-rpc', + }) + }) + + it('does not surface RPC functions without agent field', () => { + const ctx = createContext() + ctx.rpc.register(rpcDef({ + name: 'private-rpc', + type: 'query', + setup: () => ({ handler: async () => 42 }), + })) + + expect(ctx.agent.list().tools).toHaveLength(0) + }) + + it('infers safety from RPC type', () => { + const ctx = createContext() + ctx.rpc.register(rpcDef({ + name: 'q', + type: 'query', + jsonSerializable: true, + agent: { description: 'q' }, + setup: () => ({ handler: async () => {} }), + })) + ctx.rpc.register(rpcDef({ + name: 'a', + type: 'action', + jsonSerializable: true, + agent: { description: 'a' }, + setup: () => ({ handler: async () => {} }), + })) + ctx.rpc.register(rpcDef({ + name: 's', + type: 'static', + jsonSerializable: true, + agent: { description: 's' }, + setup: () => ({ handler: async () => {} }), + })) + + const tools = ctx.agent.list().tools + const byId = Object.fromEntries(tools.map(t => [t.id, t])) + expect(byId.q!.safety).toBe('read') + expect(byId.a!.safety).toBe('action') + expect(byId.s!.safety).toBe('read') + }) + + it('fires manifest:changed when a new agent RPC is registered', () => { + const ctx = createContext() + const handler = vi.fn() + ctx.agent.events.on('agent:manifest:changed', handler) + + ctx.rpc.register(rpcDef({ + name: 'x', + type: 'query', + jsonSerializable: true, + agent: { description: 'x' }, + setup: () => ({ handler: async () => {} }), + })) + + expect(handler).toHaveBeenCalled() + }) + }) + + describe('invoke()', () => { + it('dispatches to the registered tool handler', async () => { + const ctx = createContext() + const handler = vi.fn(async (args: unknown) => ({ echoed: args })) + ctx.agent.registerTool({ + id: 'echo', + description: 'Echoes input.', + handler, + }) + + const result = await ctx.agent.invoke('echo', { ping: true }) + expect(handler).toHaveBeenCalledWith({ ping: true }) + expect(result).toEqual({ echoed: { ping: true } }) + }) + + it('dispatches to an RPC function via invokeLocal', async () => { + const ctx = createContext() + ctx.rpc.register(rpcDef({ + name: 'my-rpc', + type: 'query', + jsonSerializable: true, + agent: { description: 'rpc' }, + setup: () => ({ + handler: async (a: number, b: number) => a + b, + }), + })) + + const result = await ctx.agent.invoke('my-rpc', { arg0: 2, arg1: 3 }) + expect(result).toBe(5) + }) + + it('throws for unknown tool id', async () => { + const ctx = createContext() + await expect(ctx.agent.invoke('missing', {})).rejects.toThrow(/missing/) + }) + }) + + describe('resources', () => { + it('registerResource synthesizes URI and stores the read handler', async () => { + const ctx = createContext() + ctx.agent.registerResource({ + id: 'my-resource', + name: 'My resource', + read: () => ({ json: { hello: 'world' } }), + }) + + const resources = ctx.agent.list().resources + expect(resources).toHaveLength(1) + expect(resources[0]!.uri).toBe('devframe://resource/my-resource') + + const content = await ctx.agent.read('my-resource') + expect(content).toEqual({ json: { hello: 'world' } }) + }) + + it('throws DF0016 on duplicate id', () => { + const ctx = createContext() + ctx.agent.registerResource({ + id: 'dup', + name: 'first', + read: () => ({ text: 'a' }), + }) + expect(() => ctx.agent.registerResource({ + id: 'dup', + name: 'second', + read: () => ({ text: 'b' }), + })).toThrow(/already registered/) + }) + + it('throws when reading unknown resource', async () => { + const ctx = createContext() + await expect(ctx.agent.read('ghost')).rejects.toThrow(/ghost/) + }) + }) +}) diff --git a/packages/devframe/src/node/__tests__/host-functions.test.ts b/packages/devframe/src/node/__tests__/host-functions.test.ts new file mode 100644 index 0000000..028aad9 --- /dev/null +++ b/packages/devframe/src/node/__tests__/host-functions.test.ts @@ -0,0 +1,169 @@ +import type { DevToolsNodeContext } from 'devframe/types' +import { defineRpcFunction } from 'devframe' +import { describe, expect, it } from 'vitest' +import { RpcFunctionsHost } from '../host-functions' + +async function emptyHandler() { /* empty */ } +const returnFirst = async () => 'first' +const returnSecond = async () => 'second' +const returnV1 = async () => 'v1' +const returnV2 = async () => 'v2' +const setupWith = (handler: () => Promise) => async () => ({ handler }) + +describe('rpcFunctionsHost', () => { + const mockContext = {} as DevToolsNodeContext + + describe('register() collision detection', () => { + it('should register a new RPC function successfully', () => { + const host = new RpcFunctionsHost(mockContext) + const fn = defineRpcFunction({ + name: 'test-function', + type: 'action', + setup: setupWith(emptyHandler), + }) + + expect(() => host.register(fn)).not.toThrow() + expect(host.definitions.has('test-function')).toBe(true) + }) + + it('should throw error when registering duplicate RPC function ID', () => { + const host = new RpcFunctionsHost(mockContext) + const fn1 = defineRpcFunction({ + name: 'duplicate-fn', + type: 'action', + setup: setupWith(returnFirst), + }) + const fn2 = defineRpcFunction({ + name: 'duplicate-fn', + type: 'action', + setup: setupWith(returnSecond), + }) + + host.register(fn1) + + const registerDuplicate = () => host.register(fn2) + expect(registerDuplicate).toThrow() + expect(registerDuplicate).toThrow('duplicate-fn') + expect(registerDuplicate).toThrow('already registered') + }) + + it('should include the duplicate ID in error message', () => { + const host = new RpcFunctionsHost(mockContext) + const fn = defineRpcFunction({ + name: 'my-special-function', + type: 'query', + setup: setupWith(emptyHandler), + }) + + host.register(fn) + + const registerAgain = () => host.register(fn) + expect(registerAgain).toThrow('my-special-function') + }) + }) + + describe('update() existence validation', () => { + it('should throw error when updating non-existent RPC function', () => { + const host = new RpcFunctionsHost(mockContext) + const fn = defineRpcFunction({ + name: 'nonexistent', + type: 'action', + setup: setupWith(emptyHandler), + }) + + const updateNonexistent = () => host.update(fn) + expect(updateNonexistent).toThrow() + expect(updateNonexistent).toThrow('nonexistent') + expect(updateNonexistent).toThrow('not registered') + expect(updateNonexistent).toThrow('Use register()') + }) + + it('should update existing RPC function successfully', () => { + const host = new RpcFunctionsHost(mockContext) + const fn1 = defineRpcFunction({ + name: 'update-test', + type: 'action', + setup: setupWith(returnV1), + }) + const fn2 = defineRpcFunction({ + name: 'update-test', + type: 'action', + setup: setupWith(returnV2), + }) + + host.register(fn1) + const doUpdate = () => host.update(fn2) + expect(doUpdate).not.toThrow() + + const updated = host.definitions.get('update-test') + expect(updated).toBe(fn2) + }) + + it('should validate that update only works on existing entries', () => { + const host = new RpcFunctionsHost(mockContext) + + // Register one function + host.register(defineRpcFunction({ + name: 'exists', + type: 'action', + setup: setupWith(emptyHandler), + })) + + // Update should work for existing + const updateExisting = () => + host.update({ + name: 'exists', + type: 'action', + setup: setupWith(emptyHandler), + }) + expect(updateExisting).not.toThrow() + + // Update should fail for non-existing + const updateMissing = () => + host.update({ + name: 'does-not-exist', + type: 'action', + setup: setupWith(emptyHandler), + }) + expect(updateMissing).toThrow() + }) + }) + + describe('broadcast() without rpc group', () => { + it('should not throw in build mode', async () => { + const host = new RpcFunctionsHost({ mode: 'build' } as DevToolsNodeContext) + await expect(host.broadcast({ + method: 'devframe:terminals:updated', + args: [], + })).resolves.toBeUndefined() + }) + + it('should not throw in dev mode when rpc group is not yet set', async () => { + const host = new RpcFunctionsHost({ mode: 'dev' } as DevToolsNodeContext) + await expect(host.broadcast({ + method: 'devframe:terminals:updated', + args: [], + })).resolves.toBeUndefined() + }) + }) + + describe('invokeLocal()', () => { + it('should invoke a locally registered function', async () => { + const host = new RpcFunctionsHost(mockContext) + host.register(defineRpcFunction({ + name: 'test:invoke-local', + type: 'query', + setup: () => ({ + handler: async (a: number, b: number) => a + b, + }), + })) + + await expect(host.invokeLocal('test:invoke-local' as any, 2, 3)).resolves.toBe(5) + }) + + it('should throw when invoking a missing local function', async () => { + const host = new RpcFunctionsHost(mockContext) + await expect(host.invokeLocal('test:missing' as any)).rejects.toThrow('RPC function "test:missing" is not registered') + }) + }) +}) diff --git a/packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts b/packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts new file mode 100644 index 0000000..d125765 --- /dev/null +++ b/packages/devframe/src/node/__tests__/rpc-agent-introspection.test.ts @@ -0,0 +1,70 @@ +import type { DevToolsHost } from '../../types/host' +import { describe, expect, it } from 'vitest' +import { createHostContext } from '../context' + +function nullHost(): DevToolsHost { + return { + mountStatic: () => { /* no-op */ }, + resolveOrigin: () => 'http://localhost:0', + getStorageDir: () => '/tmp/devframe-test-storage', + } +} + +describe('agent introspection RPCs', () => { + it('registers devframe:agent:list-tools and returns the tool manifest', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + + ctx.agent.registerTool({ + id: 'hello', + description: 'Say hello.', + handler: () => 'hi', + }) + + const tools = await ctx.rpc.invokeLocal('devframe:agent:list-tools' as any) as any[] + expect(tools).toHaveLength(1) + expect(tools[0]).toMatchObject({ + id: 'hello', + kind: 'tool', + description: 'Say hello.', + }) + }) + + it('routes devframe:agent:invoke-tool through the agent host', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + + let received: unknown + ctx.agent.registerTool({ + id: 'capture', + description: 'Capture args.', + handler: (args) => { + received = args + return 'ok' + }, + }) + + const result = await ctx.rpc.invokeLocal('devframe:agent:invoke-tool' as any, 'capture', { payload: 42 }) + expect(result).toBe('ok') + expect(received).toEqual({ payload: 42 }) + }) + + it('registers list-resources + read-resource', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + + ctx.agent.registerResource({ + id: 'build-summary', + name: 'Build summary', + read: () => ({ text: 'Build OK' }), + }) + + const resources = await ctx.rpc.invokeLocal('devframe:agent:list-resources' as any) as any[] + expect(resources).toHaveLength(1) + expect(resources[0]).toMatchObject({ + id: 'build-summary', + uri: 'devframe://resource/build-summary', + name: 'Build summary', + }) + + const content = await ctx.rpc.invokeLocal('devframe:agent:read-resource' as any, 'build-summary') as any + expect(content).toEqual({ text: 'Build OK' }) + }) +}) diff --git a/packages/devframe/src/node/__tests__/rpc-streaming.test.ts b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts new file mode 100644 index 0000000..e5e95db --- /dev/null +++ b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts @@ -0,0 +1,369 @@ +import type { DevToolsNodeContext, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types' +import { AsyncLocalStorage } from 'node:async_hooks' +import { createRpcStreamingClientHost } from 'devframe/client' +import { createRpcClient } from 'devframe/rpc/client' +import { createRpcServer } from 'devframe/rpc/server' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { RpcFunctionsHost } from '../host-functions' + +vi.stubGlobal('WebSocket', WebSocket) + +let nextPort = 41000 +function allocatePort(): number { + return nextPort++ +} + +interface Harness { + port: number + rpcHost: RpcFunctionsHost + close: () => Promise +} + +async function bootHost(): Promise { + const port = allocatePort() + const mockContext = {} as DevToolsNodeContext + const rpcHost = new RpcFunctionsHost(mockContext) + + const asyncStorage = new AsyncLocalStorage() + + const rpcGroup = createRpcServer( + rpcHost.functions, + { + rpcOptions: { + resolver(_name, fn) { + // eslint-disable-next-line ts/no-this-alias + const rpc = this + if (!fn) + return undefined + return async function (this: any, ...args) { + return await asyncStorage.run({ rpc, meta: rpc.$meta }, async () => { + return (await fn).apply(this, args) + }) + } + }, + }, + }, + ) + + const { wss } = attachWsRpcTransport(rpcGroup, { + port, + host: '127.0.0.1', + onDisconnected: (_ws, meta) => { + rpcHost._emitSessionDisconnected(meta) + }, + }) + + ;(rpcHost as any)._rpcGroup = rpcGroup + ;(rpcHost as any)._asyncStorage = asyncStorage + + return { + port, + rpcHost, + async close() { + for (const ws of wss.clients) ws.terminate() + await new Promise(r => wss.close(() => r())) + }, + } +} + +interface FakeClient { + rpc: ReturnType> + streaming: ReturnType + close: () => void +} + +function bootClient(port: number): FakeClient { + // Mimic the minimal `DevToolsRpcClient` surface that + // `createRpcStreamingClientHost` uses (events, isTrusted, callEvent, + // client.register). + const listeners = new Set<(trusted: boolean) => void>() + const fakeEvents = { + on(name: string, fn: (trusted: boolean) => void) { + if (name === 'rpc:is-trusted:updated') + listeners.add(fn) + return () => listeners.delete(fn) + }, + } + + // Lazily filled below, but the streaming host needs it during register(). + // We use the actual client functions object as the "client.register" target. + const clientFns: any = {} + const clientRpcStub = { + register(def: { name: string, handler: (...args: any[]) => any }) { + clientFns[def.name] = def.handler + }, + } + + const rpc = createRpcClient( + clientFns, + { + channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }), + }, + ) + + const fakeRpcClient = { + isTrusted: true, + events: fakeEvents, + client: clientRpcStub, + callEvent: (name: any, ...args: any[]) => (rpc as any).$callEvent(name, ...args), + } as any + + const streaming = createRpcStreamingClientHost(fakeRpcClient) + + return { + rpc, + streaming, + close() { + // ws closes when server tears down + }, + } +} + +describe('rpc-streaming integration', () => { + let harness: Harness + + beforeEach(async () => { + harness = await bootHost() + }) + + afterEach(async () => { + await harness.close() + }) + + it('round-trips chunks from server to client in order', async () => { + const channel = harness.rpcHost.streaming.create('test:words', { + replayWindow: 64, + }) + + const client = bootClient(harness.port) + + // give the WS a moment to connect + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'words-1' }) + const reader = client.streaming.subscribe('test:words', 'words-1') + + // small delay so subscribe arrives before producer writes + await new Promise(r => setTimeout(r, 30)) + + stream.write('alpha') + stream.write('beta') + stream.write('gamma') + stream.close() + + const collected: string[] = [] + for await (const chunk of reader) + collected.push(chunk) + + expect(collected).toEqual(['alpha', 'beta', 'gamma']) + expect(reader.done).toBe(true) + }) + + it('replays buffered chunks for a late subscriber', async () => { + const channel = harness.rpcHost.streaming.create('test:replay', { + replayWindow: 64, + }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + // Producer runs first — subscriber comes later. + const stream = channel.start({ id: 'replay-1' }) + stream.write('one') + stream.write('two') + stream.write('three') + + const reader = client.streaming.subscribe('test:replay', 'replay-1') + await new Promise(r => setTimeout(r, 50)) + stream.close() + + const collected: string[] = [] + for await (const chunk of reader) + collected.push(chunk) + + expect(collected).toEqual(['one', 'two', 'three']) + }) + + it('aborts the server stream when a client cancels (single subscriber)', async () => { + const channel = harness.rpcHost.streaming.create('test:cancel') + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'cancel-1' }) + const reader = client.streaming.subscribe('test:cancel', 'cancel-1') + + await new Promise(r => setTimeout(r, 30)) + + expect(stream.signal.aborted).toBe(false) + reader.cancel() + + await vi.waitFor(() => { + expect(stream.signal.aborted).toBe(true) + }) + }) + + it('fans out the same chunks to two subscribers', async () => { + const channel = harness.rpcHost.streaming.create('test:fanout', { + replayWindow: 8, + }) + + const a = bootClient(harness.port) + const b = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'fan-1' }) + const readerA = a.streaming.subscribe('test:fanout', 'fan-1') + const readerB = b.streaming.subscribe('test:fanout', 'fan-1') + + await new Promise(r => setTimeout(r, 30)) + + stream.write('hello') + stream.write('world') + stream.close() + + const collectedA: string[] = [] + const collectedB: string[] = [] + for await (const chunk of readerA) collectedA.push(chunk) + for await (const chunk of readerB) collectedB.push(chunk) + + expect(collectedA).toEqual(['hello', 'world']) + expect(collectedB).toEqual(['hello', 'world']) + }) + + it('uploads chunks from client to server in order', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-happy') + const reader = channel.openInbound({ id: 'up-1' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-happy', 'up-1') + sink.write('alpha') + sink.write('beta') + sink.write('gamma') + sink.close() + + const collected: string[] = [] + for await (const chunk of reader) + collected.push(chunk) + + expect(collected).toEqual(['alpha', 'beta', 'gamma']) + }) + + it('propagates client-side error to the server reader', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-error') + const reader = channel.openInbound({ id: 'up-2' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-error', 'up-2') + sink.write('hello') + sink.error(new Error('upstream-bork')) + + const collected: string[] = [] + let caught: unknown + try { + for await (const chunk of reader) + collected.push(chunk) + } + catch (e) { + caught = e + } + expect(collected).toEqual(['hello']) + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toBe('upstream-bork') + }) + + it('aborts the client sink when the server cancels', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-server-cancel') + const reader = channel.openInbound({ id: 'up-3' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-server-cancel', 'up-3') + sink.write('first') + + // Server consumes one chunk then cancels. + const consumed: string[] = [] + const consumer = (async () => { + for await (const chunk of reader) { + consumed.push(chunk) + if (consumed.length === 1) + reader.cancel() + } + })() + + await consumer + + await vi.waitFor(() => { + expect(sink.signal.aborted).toBe(true) + }) + expect(consumed).toEqual(['first']) + }) + + it('ends the server reader when the uploading client disconnects', async () => { + const channel = harness.rpcHost.streaming.create('test:upload-disconnect') + const reader = channel.openInbound({ id: 'up-4' }) + + const client = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const sink = client.streaming.upload('test:upload-disconnect', 'up-4') + sink.write('mid-flight') + + // Drain at least the first chunk before terminating. + const collected: string[] = [] + const consumer = (async () => { + try { + for await (const chunk of reader) + collected.push(chunk) + } + catch { + // Disconnect surfaces as an error end frame; that's the test path. + } + })() + + await new Promise(r => setTimeout(r, 50)) + + // Slam the WS server side so the uploading session disconnects. + await harness.close() + + await consumer + expect(collected).toEqual(['mid-flight']) + }) + + it('keeps the stream alive when one of two subscribers cancels', async () => { + const channel = harness.rpcHost.streaming.create('test:fanout-cancel', { + replayWindow: 8, + }) + + const a = bootClient(harness.port) + const b = bootClient(harness.port) + await new Promise(r => setTimeout(r, 50)) + + const stream = channel.start({ id: 'fan-2' }) + const readerA = a.streaming.subscribe('test:fanout-cancel', 'fan-2') + const readerB = b.streaming.subscribe('test:fanout-cancel', 'fan-2') + + await new Promise(r => setTimeout(r, 30)) + + stream.write('first') + await new Promise(r => setTimeout(r, 30)) + readerA.cancel() + await new Promise(r => setTimeout(r, 30)) + + expect(stream.signal.aborted).toBe(false) + + stream.write('second') + stream.close() + + const collectedB: string[] = [] + for await (const chunk of readerB) collectedB.push(chunk) + expect(collectedB).toEqual(['first', 'second']) + }) +}) diff --git a/packages/devframe/src/node/__tests__/static-dump.test.ts b/packages/devframe/src/node/__tests__/static-dump.test.ts new file mode 100644 index 0000000..18906b1 --- /dev/null +++ b/packages/devframe/src/node/__tests__/static-dump.test.ts @@ -0,0 +1,219 @@ +import { defineRpcFunction } from 'devframe' +import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' +import { strictJsonStringify } from 'devframe/rpc' +import { structuredCloneDeserialize, structuredCloneStringify } from 'devframe/utils/structured-clone' +import { describe, expect, it } from 'vitest' +import { collectStaticRpcDump } from '../static-dump' + +describe('collectStaticRpcDump', () => { + it('tags entries as JSON when jsonSerializable: true is declared', async () => { + const getVersion = defineRpcFunction({ + name: 'test:json-version', + type: 'static', + jsonSerializable: true, + handler: () => '1.0.0', + }) + + const result = await collectStaticRpcDump([getVersion], {}) + const expectedPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~json-version.static.json` + + expect(result.manifest['test:json-version']).toEqual({ + type: 'static', + path: expectedPath, + serialization: 'json', + }) + expect(result.files[expectedPath]?.serialization).toBe('json') + }) + + it('collects static rpc output into sharded file entries', async () => { + const getVersion = defineRpcFunction({ + name: 'test:get-version', + type: 'static', + handler: () => '1.0.0', + }) + + const result = await collectStaticRpcDump([getVersion], {}) + const expectedPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~get-version.static.json` + + expect(result.manifest['test:get-version']).toEqual({ + type: 'static', + path: expectedPath, + // Default `jsonSerializable: false` → structured-clone-encoded shard. + serialization: 'structured-clone', + }) + expect(result.files[expectedPath]).toEqual({ + serialization: 'structured-clone', + fnName: 'test:get-version', + data: { output: '1.0.0' }, + }) + }) + + it('collects query dumps with records and fallback shards', async () => { + const getItem = defineRpcFunction({ + name: 'test:get-item', + type: 'query', + handler: (id: string) => ({ id }), + dump: { + inputs: [ + ['a'], + ['b'], + ], + fallback: null, + }, + }) + + const result = await collectStaticRpcDump([getItem], {}) + const basePath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~get-item` + const manifest = result.manifest['test:get-item'] as { + type: 'query' + records: Record + fallback?: string + } + + expect(manifest).toEqual({ + type: 'query', + records: expect.any(Object), + fallback: `${basePath}.fallback.json`, + serialization: 'structured-clone', + }) + expect(Object.keys(manifest.records)).toHaveLength(2) + + const recordPaths = Object.values(manifest.records) + for (const path of recordPaths) { + expect(path.startsWith(`${basePath}.record.`)).toBe(true) + expect(path.endsWith('.json')).toBe(true) + expect(path in result.files).toBe(true) + } + + expect(Object.keys(result.files).some(path => path.endsWith('.index.json'))).toBe(false) + }) + + it('skips query functions without dump config', async () => { + const getLive = defineRpcFunction({ + name: 'test:get-live', + type: 'query', + handler: () => ({ ok: true }), + }) + + const result = await collectStaticRpcDump([getLive], {}) + + expect(result.manifest).toEqual({}) + expect(result.files).toEqual({}) + }) + + describe('structured-clone dumps', () => { + it('keeps Map/Set values intact in the in-memory file payload', async () => { + const getGraph = defineRpcFunction({ + name: 'test:graph', + type: 'static', + // jsonSerializable: false (default) — fancy types must survive + handler: () => ({ + nodes: new Map([['a', 1], ['b', 2]]), + tags: new Set(['x', 'y']), + }), + }) + + const result = await collectStaticRpcDump([getGraph], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~graph.static.json` + const file = result.files[path]! + + expect(file.serialization).toBe('structured-clone') + const output = (file.data as { output: { nodes: Map, tags: Set } }).output + expect(output.nodes).toBeInstanceOf(Map) + expect(output.nodes.get('a')).toBe(1) + expect(output.tags).toBeInstanceOf(Set) + expect(output.tags.has('x')).toBe(true) + }) + + it('survives a full write→read round-trip (Map preserved end-to-end)', async () => { + // Mirrors what `createBuild` does: collect, sc-stringify the file + // payload, write JSON text to disk. The static client later reads + // the JSON and revives via `structuredCloneDeserialize`. + const getMap = defineRpcFunction({ + name: 'test:roundtrip-map', + type: 'static', + handler: () => new Map([['k', 42]]), + }) + + const result = await collectStaticRpcDump([getMap], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~roundtrip-map.static.json` + const file = result.files[path]! + + // Server side: write to disk as sc-encoded text. + const wireText = structuredCloneStringify(file.data) + // Client side: fetch().json() (i.e. JSON.parse) + structuredCloneDeserialize revive. + const revived = structuredCloneDeserialize(JSON.parse(wireText)) as { output: Map } + expect(revived.output).toBeInstanceOf(Map) + expect(revived.output.get('k')).toBe(42) + }) + + it('encodes query records and fallback as structured-clone when default', async () => { + const getEntries = defineRpcFunction({ + name: 'test:entries', + type: 'query', + // default jsonSerializable: false → sc shards. + handler: (key: string) => new Map([[key, key.length]]), + dump: { + inputs: [['hello']], + fallback: new Map([['_', 0]]), + }, + }) + + const result = await collectStaticRpcDump([getEntries], {}) + const fallbackPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~entries.fallback.json` + const fallback = result.files[fallbackPath]! + expect(fallback.serialization).toBe('structured-clone') + + // Round-trip the fallback shard. + const revived = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(fallback.data))) as { output: Map } + expect(revived.output).toBeInstanceOf(Map) + expect(revived.output.get('_')).toBe(0) + + // And one of the input records. + const recordPath = Object.values( + (result.manifest['test:entries'] as { records: Record }).records, + )[0]! + const record = result.files[recordPath]! + expect(record.serialization).toBe('structured-clone') + const revivedRecord = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(record.data))) as { output: Map } + expect(revivedRecord.output.get('hello')).toBe(5) + }) + + it('writes plain JSON when jsonSerializable: true is declared', async () => { + const getList = defineRpcFunction({ + name: 'test:json-list', + type: 'static', + jsonSerializable: true, + handler: () => ['a', 'b', 'c'], + }) + + const result = await collectStaticRpcDump([getList], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~json-list.static.json` + const file = result.files[path]! + + expect(file.serialization).toBe('json') + // Strict JSON serializer round-trips losslessly via JSON.parse. + const wireText = strictJsonStringify(file.data, file.fnName) + expect(JSON.parse(wireText)).toEqual({ output: ['a', 'b', 'c'] }) + }) + + it('throws DF0019 at build time when a JSON-flagged fn returns non-JSON', async () => { + const getMapJson = defineRpcFunction({ + name: 'test:bad-json', + type: 'static', + jsonSerializable: true, + // Lying about the contract: handler returns a Map. + handler: () => new Map([['k', 1]]) as any, + }) + + const result = await collectStaticRpcDump([getMapJson], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~bad-json.static.json` + const file = result.files[path]! + + // collectStaticRpcDump records the value as-is; the strict + // serializer throws when build.ts tries to write it. + expect(() => strictJsonStringify(file.data, file.fnName)) + .toThrowError(/jsonSerializable: true.*is a Map/) + }) + }) +}) diff --git a/packages/devframe/src/node/__tests__/storage.test.ts b/packages/devframe/src/node/__tests__/storage.test.ts new file mode 100644 index 0000000..8fbf93f --- /dev/null +++ b/packages/devframe/src/node/__tests__/storage.test.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createStorage } from '../storage' + +function wait(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +describe('createStorage', () => { + it('falls back to initial value when persisted JSON is invalid', async () => { + const dir = fs.mkdtempSync(join(os.tmpdir(), 'vite-devtools-storage-')) + const filepath = join(dir, 'state.json') + fs.writeFileSync(filepath, '{invalid json', 'utf-8') + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const state = createStorage({ + filepath, + initialValue: { count: 1 }, + debounce: 0, + }) + + expect(state.value()).toEqual({ count: 1 }) + expect(warnSpy).toHaveBeenCalled() + + state.mutate((draft) => { + draft.count = 2 + }) + + await wait(20) + + const saved = JSON.parse(fs.readFileSync(filepath, 'utf-8')) + expect(saved).toEqual({ count: 2 }) + } + finally { + warnSpy.mockRestore() + fs.rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/devframe/src/node/__tests__/utils.test.ts b/packages/devframe/src/node/__tests__/utils.test.ts new file mode 100644 index 0000000..bbce76f --- /dev/null +++ b/packages/devframe/src/node/__tests__/utils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { normalizeHttpServerUrl } from '../utils' + +describe('normalizeHttpServerUrl', () => { + it('formats ipv4 localhost as localhost', () => { + expect(normalizeHttpServerUrl('127.0.0.1', 9999)).toBe('http://localhost:9999') + }) + + it('wraps ipv6 hosts in brackets', () => { + expect(normalizeHttpServerUrl('::1', 9999)).toBe('http://[::1]:9999') + }) + + it('preserves non-ip hosts', () => { + expect(normalizeHttpServerUrl('localhost', 9999)).toBe('http://localhost:9999') + }) +}) diff --git a/packages/devframe/src/node/auth/index.ts b/packages/devframe/src/node/auth/index.ts new file mode 100644 index 0000000..d82fba1 --- /dev/null +++ b/packages/devframe/src/node/auth/index.ts @@ -0,0 +1,2 @@ +export * from './revoke' +export * from './state' diff --git a/packages/devframe/src/node/auth/revoke.ts b/packages/devframe/src/node/auth/revoke.ts new file mode 100644 index 0000000..23208b1 --- /dev/null +++ b/packages/devframe/src/node/auth/revoke.ts @@ -0,0 +1,52 @@ +import type { DevToolsNodeContext } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { RpcFunctionsHost } from '../host-functions' +import type { InternalAnonymousAuthStorage } from '../internal/context' + +/** + * Flip `isTrusted` to false on any live WS clients connected with `token` + * and broadcast the `auth:revoked` event so they can react. + * + * Shared between persisted-auth revocation and remote-dock token revocation. + */ +export async function revokeActiveConnectionsForToken( + context: DevToolsNodeContext, + token: string, +): Promise { + const rpcHost = context.rpc as unknown as RpcFunctionsHost | undefined + if (!rpcHost?._rpcGroup) + return + + const affectedSessionIds = new Set() + for (const client of rpcHost._rpcGroup.clients) { + if (client.$meta.clientAuthToken === token) { + affectedSessionIds.add(client.$meta.id) + client.$meta.isTrusted = false + client.$meta.clientAuthToken = undefined! + } + } + + if (affectedSessionIds.size === 0) + return + + await rpcHost.broadcast({ + method: 'devframe:auth:revoked', + args: [], + filter: client => affectedSessionIds.has(client.$meta.id), + }) +} + +/** + * Revoke an auth token: remove from storage and notify all connected clients + * using this token that they are no longer trusted. + */ +export async function revokeAuthToken( + context: DevToolsNodeContext, + storage: SharedState, + token: string, +): Promise { + storage.mutate((state) => { + delete state.trusted[token] + }) + await revokeActiveConnectionsForToken(context, token) +} diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts new file mode 100644 index 0000000..f6cd43c --- /dev/null +++ b/packages/devframe/src/node/auth/state.ts @@ -0,0 +1,87 @@ +import type { DevToolsNodeRpcSession } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { InternalAnonymousAuthStorage } from '../internal/context' +import { humanId } from 'devframe/utils/human-id' + +export interface PendingAuthRequest { + clientAuthToken: string + session: DevToolsNodeRpcSession + ua: string + origin: string + resolve: (result: { isTrusted: boolean }) => void + abortController: AbortController + timeout: ReturnType +} + +let pendingAuth: PendingAuthRequest | null = null +let tempAuthToken: string = generateTempId() + +function generateTempId(): string { + return humanId() +} + +export function getTempAuthToken(): string { + return tempAuthToken +} + +export function refreshTempAuthToken(): string { + tempAuthToken = generateTempId() + return tempAuthToken +} + +export function getPendingAuth(): PendingAuthRequest | null { + return pendingAuth +} + +export function setPendingAuth(request: PendingAuthRequest | null): void { + pendingAuth = request +} + +/** + * Abort and clean up any existing pending auth request. + */ +export function abortPendingAuth(): void { + if (pendingAuth) { + pendingAuth.abortController.abort() + clearTimeout(pendingAuth.timeout) + pendingAuth = null + } +} + +/** + * Consume the temp auth ID: verify it matches, trust the pending client, and clean up. + * Returns the client's authToken if successful, null otherwise. + */ +export function consumeTempAuthToken( + id: string, + storage: SharedState, +): string | null { + if (id !== tempAuthToken || !pendingAuth) { + return null + } + + const { clientAuthToken, session, ua, origin, resolve } = pendingAuth + + // Trust the pending client + storage.mutate((state) => { + state.trusted[clientAuthToken] = { + authToken: clientAuthToken, + ua, + origin, + timestamp: Date.now(), + } + }) + session.meta.clientAuthToken = clientAuthToken + session.meta.isTrusted = true + + // Resolve the pending auth RPC call + resolve({ isTrusted: true }) + + // Abort terminal prompt and clean up + abortPendingAuth() + + // Generate a new temp ID for next use + refreshTempAuthToken() + + return clientAuthToken +} diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts new file mode 100644 index 0000000..d699f4d --- /dev/null +++ b/packages/devframe/src/node/context.ts @@ -0,0 +1,72 @@ +import type { RpcFunctionDefinitionAny } from 'devframe/rpc' +import type { DevToolsHost, DevToolsNodeContext } from 'devframe/types' +import { diagnostics as rpcDiagnostics } from '../rpc/diagnostics' +import { diagnostics as devframeDiagnostics } from './diagnostics' +import { DevToolsAgentHost } from './host-agent' +import { DevToolsDiagnosticsHost } from './host-diagnostics' +import { RpcFunctionsHost } from './host-functions' +import { DevToolsViewHost } from './host-views' +import { BUILTIN_AGENT_RPC } from './rpc' + +export interface CreateHostContextOptions { + cwd: string + workspaceRoot?: string + mode: 'dev' | 'build' + host: DevToolsHost + /** + * Built-in RPC declarations to register on the host. Framework + * adapters (vite, rolldown, cli) can pass the ones they need; the + * host itself has no opinions about the built-in set. + */ + builtinRpcDeclarations?: readonly RpcFunctionDefinitionAny[] +} + +/** + * Framework-neutral core of the DevTools node context. Wires the RPC + * host, view (HTTP file-serving) host, diagnostics, and agent + * subsystems. Hub-level subsystems (`docks`, `terminals`, `messages`, + * `commands`, `createJsonRenderer`) are owned by + * `@vitejs/devtools-kit` — its `createKitContext` wraps this and + * attaches them when the devframe is mounted into a multi-integration + * hub. + */ +export async function createHostContext(options: CreateHostContextOptions): Promise { + const { cwd, workspaceRoot = cwd, mode, host, builtinRpcDeclarations = [] } = options + + const context: DevToolsNodeContext = { + cwd, + workspaceRoot, + mode, + host, + rpc: undefined!, + views: undefined!, + diagnostics: undefined!, + agent: undefined!, + } as unknown as DevToolsNodeContext + + const rpcHost = new RpcFunctionsHost(context) + const viewsHost = new DevToolsViewHost(context) + const diagnosticsHost = new DevToolsDiagnosticsHost(context, [devframeDiagnostics, rpcDiagnostics]) + context.rpc = rpcHost + context.views = viewsHost + context.diagnostics = diagnosticsHost + + // Agent host must be constructed after `rpcHost` so it can subscribe + // to `onChanged` — it auto-discovers RPC functions flagged with + // the `agent` field. + const agentHost = new DevToolsAgentHost(context) + context.agent = agentHost + + // Auto-register devframe's own agent introspection RPCs. These power + // the MCP adapter and any future agent CLI. They are not themselves + // agent-exposed (no `agent` field). + for (const fn of BUILTIN_AGENT_RPC) { + rpcHost.register(fn) + } + + for (const fn of builtinRpcDeclarations) { + rpcHost.register(fn) + } + + return context +} diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts new file mode 100644 index 0000000..f7e31bc --- /dev/null +++ b/packages/devframe/src/node/diagnostics.ts @@ -0,0 +1,72 @@ +import { consoleReporter, createLogger, defineDiagnostics } from 'logs-sdk' +import { ansiFormatter } from 'logs-sdk/formatters/ansi' +import { colors as c } from '../utils/colors' + +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DF0006: { + message: (p: { name: string }) => `RPC function "${p.name}" is not registered`, + }, + DF0007: { + message: 'AsyncLocalStorage is not set, it likely to be an internal bug of the DevTools foundation', + }, + DF0008: { + message: (p: { distDir: string }) => `distDir ${p.distDir} does not exist`, + }, + DF0012: { + message: (p: { filepath: string }) => `Failed to parse storage file: ${p.filepath}, falling back to defaults.`, + level: 'warn', + }, + DF0013: { + message: (p: { key: string }) => `Shared state of "${p.key}" is not found, please provide an initial value for the first time`, + }, + DF0014: { + message: (p: { name: string }) => `RPC function "${p.name}" has an invalid \`agent\` field — \`description\` must be a non-empty string.`, + hint: 'Provide a short description (~1–3 sentences) explaining what the tool does and when agents should invoke it.', + }, + DF0015: { + message: (p: { id: string }) => `Agent tool "${p.id}" is already registered.`, + hint: 'Tool ids must be unique across RPC functions with an `agent` field and tools registered via `ctx.agent.registerTool()`.', + }, + DF0016: { + message: (p: { id: string }) => `Agent resource "${p.id}" is already registered.`, + }, + DF0017: { + message: (p: { transport: string, reason: string }) => `Failed to start MCP server (${p.transport}): ${p.reason}`, + }, + DF0029: { + message: (p: { channel: string, id: string, dropped: number }) => + `Stream "${p.channel}#${p.id}" dropped ${p.dropped} chunk(s) after exceeding the client high-water mark.`, + hint: 'The consumer is too slow for the producer. Raise `highWaterMark` on the subscription, slow the producer, or batch chunks.', + level: 'warn', + }, + DF0030: { + message: (p: { channel: string, id: string }) => + `Stream "${p.channel}#${p.id}" is unknown — no producer has called \`channel.start({ id: "${p.id}" })\`.`, + hint: 'Ensure the server-side producer is running before clients subscribe, or check for typos in the stream id.', + }, + DF0031: { + message: (p: { channel: string, id: string }) => + `Cannot write to closed stream "${p.channel}#${p.id}".`, + hint: 'Track the producer lifecycle — guard writes with the `stream.signal.aborted` flag.', + }, + DF0032: { + message: (p: { channel: string }) => + `Streaming channel "${p.channel}" is already registered.`, + hint: 'Each channel name must be unique within a context. Pick a different name or reuse the existing channel handle.', + }, + DF0033: { + message: (p: { id: string, reason: string }) => + `Failed to start dev RPC bridge for "${p.id}": ${p.reason}`, + hint: 'Verify the bridge port is free and the devframe setup function does not throw. Pin a port via `cli.port` / `cli.portRange` on the definition, or via `devMiddleware.port` on `createVitePlugin`.', + level: 'warn', + }, + }, +}) + +export const logger = createLogger({ + diagnostics: [diagnostics], + formatter: ansiFormatter(c), + reporters: consoleReporter, +}) diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts new file mode 100644 index 0000000..076405a --- /dev/null +++ b/packages/devframe/src/node/host-agent.ts @@ -0,0 +1,251 @@ +import type { RpcFunctionDefinitionAnyWithContext, RpcFunctionType } from 'devframe/rpc' +import type { + AgentHandle, + AgentManifest, + AgentResource, + AgentResourceContent, + AgentResourceInput, + AgentTool, + AgentToolInput, + DevToolsAgentHostEvents, + DevToolsAgentHost as DevToolsAgentHostType, + DevToolsNodeContext, + EventEmitter, + RpcFunctionAgentOptions, +} from 'devframe/types' +import { createEventEmitter } from 'devframe/utils/events' +import { logger } from './diagnostics' + +interface RegisteredTool { + readonly tool: AgentTool + readonly handler?: (args: any) => unknown | Promise +} + +interface RegisteredResource { + readonly resource: AgentResource + readonly read: () => Promise | AgentResourceContent +} + +/** + * Framework-neutral host aggregating the agent-exposed surface of a + * devframe. Auto-discovers RPC functions with an `agent` field from + * `ctx.rpc.definitions`, and accepts plugin-registered tools / + * resources via `registerTool` / `registerResource`. + * + * @experimental + */ +export class DevToolsAgentHost implements DevToolsAgentHostType { + public readonly events: EventEmitter = createEventEmitter() + + private readonly tools = new Map() + private readonly resources = new Map() + private _rpcUnsubscribe: (() => void) | undefined + + constructor( + public readonly context: DevToolsNodeContext, + ) { + // Watch the RPC host for new `agent`-flagged definitions. + this._rpcUnsubscribe = context.rpc.onChanged(() => { + this.events.emit('agent:manifest:changed') + }) + } + + registerTool(input: AgentToolInput): AgentHandle { + this._validateToolId(input.id) + + const tool = this._projectTool(input) + this.tools.set(tool.id, { tool, handler: input.handler }) + this.events.emit('agent:tool:registered', tool) + this.events.emit('agent:manifest:changed') + + return { + unregister: () => this.unregisterTool(tool.id), + } + } + + unregisterTool(id: string): boolean { + const existed = this.tools.delete(id) + if (existed) { + this.events.emit('agent:tool:unregistered', id) + this.events.emit('agent:manifest:changed') + } + return existed + } + + registerResource(input: AgentResourceInput): AgentHandle { + if (this.resources.has(input.id)) + throw logger.DF0016({ id: input.id }).throw() + + const resource: AgentResource = { + id: input.id, + name: input.name, + description: input.description, + mimeType: input.mimeType ?? 'application/json', + uri: input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}`, + } + this.resources.set(resource.id, { resource, read: input.read }) + this.events.emit('agent:resource:registered', resource) + this.events.emit('agent:manifest:changed') + + return { + unregister: () => this.unregisterResource(resource.id), + } + } + + unregisterResource(id: string): boolean { + const existed = this.resources.delete(id) + if (existed) { + this.events.emit('agent:resource:unregistered', id) + this.events.emit('agent:manifest:changed') + } + return existed + } + + list(): AgentManifest { + const rpcTools = this._collectRpcTools() + const plainTools = Array.from(this.tools.values()).map(t => t.tool) + const resources = Array.from(this.resources.values()).map(r => r.resource) + return { + tools: [...rpcTools, ...plainTools], + resources, + } + } + + getTool(id: string): AgentTool | undefined { + const plain = this.tools.get(id) + if (plain) + return plain.tool + return this._collectRpcTools().find(t => t.id === id) + } + + getResource(id: string): AgentResource | undefined { + return this.resources.get(id)?.resource + } + + async invoke(id: string, args: unknown): Promise { + const plain = this.tools.get(id) + if (plain?.handler) { + return await plain.handler(args) + } + + const rpcDef = this._findRpcDefinition(id) + if (rpcDef) { + // RPC args are positional. Accept an object keyed by `arg0..argN` + // (what the MCP adapter sends after flattening), or a plain array. + const positional = this._coercePositionalArgs(args, rpcDef) + return await this.context.rpc.invokeLocal(id as any, ...(positional as any)) + } + + throw new Error(`[devframe/agent] tool "${id}" not found`) + } + + async read(id: string): Promise { + const entry = this.resources.get(id) + if (!entry) + throw new Error(`[devframe/agent] resource "${id}" not found`) + return await entry.read() + } + + /** @internal */ + _dispose(): void { + this._rpcUnsubscribe?.() + this._rpcUnsubscribe = undefined + } + + private _validateToolId(id: string): void { + if (this.tools.has(id)) + throw logger.DF0015({ id }).throw() + // Collision with an RPC function that already carries an `agent` field. + const rpcDef = this.context.rpc.definitions.get(id) + if (rpcDef?.agent) + throw logger.DF0015({ id }).throw() + } + + private _projectTool(input: AgentToolInput): AgentTool { + if (!input.description || typeof input.description !== 'string') + throw logger.DF0014({ name: input.id }).throw() + + return { + id: input.id, + kind: 'tool', + title: input.title ?? input.id, + description: input.description, + safety: input.safety ?? 'action', + tags: input.tags, + inputSchema: input.inputSchema, + outputSchema: input.outputSchema, + examples: input.examples, + } + } + + private _collectRpcTools(): AgentTool[] { + const out: AgentTool[] = [] + for (const [name, def] of this.context.rpc.definitions) { + const agent = def.agent as RpcFunctionAgentOptions | undefined + if (!agent) + continue + if (!agent.description || typeof agent.description !== 'string') + throw logger.DF0014({ name }).throw() + + const type: RpcFunctionType = def.type ?? 'query' + const safety = agent.safety ?? inferSafety(type) + out.push({ + id: name, + kind: 'rpc', + title: agent.title ?? name, + description: agent.description, + safety, + tags: agent.tags, + rpcName: name, + examples: agent.examples, + // Schemas are carried by the definition itself — consumers + // (e.g. the MCP adapter) convert valibot → JSON Schema on demand. + }) + } + return out + } + + private _findRpcDefinition(id: string): RpcFunctionDefinitionAnyWithContext | undefined { + const def = this.context.rpc.definitions.get(id) + if (def?.agent) + return def + return undefined + } + + private _coercePositionalArgs( + args: unknown, + def: RpcFunctionDefinitionAnyWithContext, + ): unknown[] { + if (Array.isArray(args)) + return args + if (args === undefined || args === null) + return [] + if (args && typeof args === 'object') { + const obj = args as Record + const schemas = def.args as readonly unknown[] | undefined + if (schemas && schemas.length) + return schemas.map((_, i) => obj[`arg${i}`]) + // Fallback: detect arg0/arg1/... keys even without schemas. + if (hasPositionalKeys(obj)) { + const out: unknown[] = [] + let i = 0 + while (`arg${i}` in obj) { + out.push(obj[`arg${i}`]) + i++ + } + return out + } + } + return [args] + } +} + +function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' { + if (type === 'static' || type === 'query') + return 'read' + return 'action' +} + +function hasPositionalKeys(obj: Record): boolean { + return 'arg0' in obj +} diff --git a/packages/devframe/src/node/host-diagnostics.ts b/packages/devframe/src/node/host-diagnostics.ts new file mode 100644 index 0000000..9b6ab8e --- /dev/null +++ b/packages/devframe/src/node/host-diagnostics.ts @@ -0,0 +1,37 @@ +import type { DevToolsDiagnosticsHost as DevToolsDiagnosticsHostType, DevToolsDiagnosticsLogger, DevToolsNodeContext } from 'devframe/types' +import { consoleReporter, createLogger, defineDiagnostics } from 'logs-sdk' +import { ansiFormatter } from 'logs-sdk/formatters/ansi' +import { colors as c } from '../utils/colors' + +export class DevToolsDiagnosticsHost implements DevToolsDiagnosticsHostType { + private _definitions: unknown[] = [] + private _logger!: DevToolsDiagnosticsLogger + + readonly defineDiagnostics: typeof defineDiagnostics = defineDiagnostics + readonly createLogger: typeof createLogger = createLogger + + constructor( + public readonly context: DevToolsNodeContext, + initialDefinitions: unknown[] = [], + ) { + this._definitions = [...initialDefinitions] + this._rebuild() + } + + get logger(): DevToolsDiagnosticsLogger { + return this._logger + } + + register(definitions: unknown): void { + this._definitions.push(definitions) + this._rebuild() + } + + private _rebuild(): void { + this._logger = createLogger({ + diagnostics: this._definitions as any[], + formatter: ansiFormatter(c), + reporters: consoleReporter, + }) as DevToolsDiagnosticsLogger + } +} diff --git a/packages/devframe/src/node/host-functions.ts b/packages/devframe/src/node/host-functions.ts new file mode 100644 index 0000000..66c4a30 --- /dev/null +++ b/packages/devframe/src/node/host-functions.ts @@ -0,0 +1,86 @@ +import type { BirpcGroup } from 'birpc' +import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsNodeRpcSessionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, RpcBroadcastOptions, RpcFunctionsHost as RpcFunctionsHostType, RpcSharedStateHost, RpcStreamingHost } from 'devframe/types' +import type { AsyncLocalStorage } from 'node:async_hooks' +import { RpcFunctionsCollectorBase } from 'devframe/rpc' +import { createDebug } from 'obug' +import { logger } from './diagnostics' +import { createRpcSharedStateServerHost } from './rpc-shared-state' +import { createRpcStreamingServerHost } from './rpc-streaming' + +const debugBroadcast = createDebug('vite:devtools:rpc:broadcast') + +export class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHostType { + /** + * @internal + */ + _rpcGroup: BirpcGroup = undefined! + _asyncStorage: AsyncLocalStorage = undefined! + + constructor(context: DevToolsNodeContext) { + super(context) + + this.sharedState = createRpcSharedStateServerHost(this) + this.streaming = createRpcStreamingServerHost(this) + } + + sharedState: RpcSharedStateHost + streaming: RpcStreamingHost + + /** + * Adapters call this from their WS `onDisconnected` hook so downstream + * hosts (streaming, …) can free per-session state. Public-ish because + * tests / custom adapters may want to mirror it. + * + * @internal + */ + _emitSessionDisconnected(meta: DevToolsNodeRpcSessionMeta): void { + this.streaming._onSessionDisconnected(meta) + } + + async invokeLocal< + T extends keyof DevToolsRpcServerFunctions, + Args extends Parameters, + >( + method: T, + ...args: Args + ): Promise>> { + if (!this.definitions.has(method as string)) { + throw logger.DF0006({ name: String(method) }).throw() + } + + const handler = await this.getHandler(method) + return await Promise.resolve( + (handler as (...args: Args) => ReturnType)(...args), + ) as Awaited> + } + + async broadcast< + T extends keyof DevToolsRpcClientFunctions, + Args extends Parameters, + >( + options: RpcBroadcastOptions, + ): Promise { + if (!this._rpcGroup) + return + + debugBroadcast(JSON.stringify(options.method)) + + await Promise.allSettled( + this._rpcGroup.clients.map((client) => { + if (options.filter?.(client) === false) + return undefined + return client.$callRaw({ + optional: true, + event: true, + ...options, + }) + }), + ) + } + + getCurrentRpcSession(): DevToolsNodeRpcSession | undefined { + if (!this._asyncStorage) + throw logger.DF0007().throw() + return this._asyncStorage.getStore() + } +} diff --git a/packages/devframe/src/node/host-h3.ts b/packages/devframe/src/node/host-h3.ts new file mode 100644 index 0000000..faf2b45 --- /dev/null +++ b/packages/devframe/src/node/host-h3.ts @@ -0,0 +1,54 @@ +import type { DevToolsHost } from '../types/host' +import { homedir } from 'node:os' +import process from 'node:process' +import { join } from 'pathe' + +export interface CreateH3DevToolsHostOptions { + /** The h3 app instance — registered once the CLI adapter lands. */ + app?: unknown + /** + * Host the standalone server listens on, e.g. `http://localhost:9999`. + * Consumed by `resolveOrigin` for dock entries that need an absolute URL. + */ + origin: string + /** + * Register a static-file handler at `base` serving files from `distDir`. + * Wired into the h3 app once the CLI adapter lands (commit 5). For now + * the CLI isn't running, so the default is a no-op. + */ + mount?: (base: string, distDir: string) => void | Promise + /** + * Namespace for storage paths returned by `getStorageDir`. Workspace + * state lives under `${workspaceRoot}/node_modules/./devtools/` + * and global state under `${homedir()}/./devtools/`. Pick the + * devtool's id (or another stable, filesystem-safe identifier) so the + * standalone host doesn't collide with other tools' storage. + */ + appName: string + /** + * Workspace root used as the parent of the per-project storage + * directory. Defaults to `process.cwd()`. + */ + workspaceRoot?: string +} + +/** + * h3-backed {@link DevToolsHost} — used by the standalone CLI adapter. + */ +export function createH3DevToolsHost(options: CreateH3DevToolsHostOptions): DevToolsHost { + const workspaceRoot = options.workspaceRoot ?? process.cwd() + return { + mountStatic(base, distDir) { + return options.mount?.(base, distDir) + }, + resolveOrigin() { + return options.origin + }, + getStorageDir(scope) { + const namespace = `.${options.appName}/devtools` + return scope === 'workspace' + ? join(workspaceRoot, 'node_modules', namespace) + : join(homedir(), namespace) + }, + } +} diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts new file mode 100644 index 0000000..b217f50 --- /dev/null +++ b/packages/devframe/src/node/host-views.ts @@ -0,0 +1,24 @@ +import type { DevToolsNodeContext, DevToolsViewHost as DevToolsViewHostType } from 'devframe/types' +import { existsSync } from 'node:fs' +import { logger } from './diagnostics' + +export class DevToolsViewHost implements DevToolsViewHostType { + /** + * @internal + */ + public buildStaticDirs: { baseUrl: string, distDir: string }[] = [] + + constructor( + public readonly context: DevToolsNodeContext, + ) { + } + + hostStatic(baseUrl: string, distDir: string) { + if (!existsSync(distDir)) { + throw logger.DF0008({ distDir }).throw() + } + + this.buildStaticDirs.push({ baseUrl, distDir }) + this.context.host.mountStatic(baseUrl, distDir) + } +} diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts new file mode 100644 index 0000000..6563fe1 --- /dev/null +++ b/packages/devframe/src/node/index.ts @@ -0,0 +1,13 @@ +// Node-side public API for consumers that wire up their own runtime. +export * from './context' +export * from './host-agent' +export * from './host-diagnostics' +export * from './host-functions' +export * from './host-h3' +export * from './host-views' +export * from './rpc-shared-state' +export * from './rpc-streaming' +export * from './server' +export * from './static-dump' +export * from './storage' +export * from './utils' diff --git a/packages/devframe/src/node/internal/context.ts b/packages/devframe/src/node/internal/context.ts new file mode 100644 index 0000000..20e0081 --- /dev/null +++ b/packages/devframe/src/node/internal/context.ts @@ -0,0 +1,109 @@ +import type { DevToolsNodeContext } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import { humanId } from 'devframe/utils/human-id' +import { join } from 'pathe' +import { revokeActiveConnectionsForToken, revokeAuthToken } from '../auth/revoke' +import { createStorage } from '../storage' + +export interface InternalAnonymousAuthStorage { + trusted: Record +} + +export interface RemoteTokenRecord { + dockId: string + /** Dock URL origin — matched against WS handshake `Origin` header when `originLock` is on. */ + origin: string + originLock: boolean +} + +export interface DevToolsInternalContext { + storage: { + auth: SharedState + } + /** + * Revoke an auth token: remove from storage and notify all connected clients + * using this token that they are no longer trusted. + */ + revokeAuthToken: (token: string) => Promise + + /** + * Session-only tokens issued to remote-UI iframe docks. Not persisted — + * regenerated on every dev-server restart. + */ + remoteTokens: Map + allocateRemoteToken: (dockId: string, origin: string, originLock: boolean) => string + revokeRemoteToken: (token: string) => void + revokeRemoteTokensForDock: (dockId: string) => void + /** + * Returns true if `token` is a valid remote token and, when `originLock` is + * on, `requestOrigin` matches the recorded dock origin. + */ + isRemoteTokenTrusted: (token: string, requestOrigin?: string) => boolean + + /** + * Populated by `createWsServer` once the WS port is bound. Consumed by the + * docks host when enriching remote iframe URLs with a connection descriptor. + */ + wsEndpoint?: { + /** Full `ws://` or `wss://` URL with host and port. */ + url: string + } +} + +export const internalContextMap = new WeakMap() + +export function getInternalContext(context: DevToolsNodeContext): DevToolsInternalContext { + if (!internalContextMap.has(context)) { + const storage = createStorage({ + filepath: join(context.host.getStorageDir('global'), 'auth.json'), + initialValue: { + trusted: {}, + }, + }) + const remoteTokens = new Map() + + function revokeRemoteToken(token: string): void { + if (!remoteTokens.delete(token)) + return + void revokeActiveConnectionsForToken(context, token) + } + + const internalContext: DevToolsInternalContext = { + storage: { + auth: storage, + }, + revokeAuthToken: (token: string) => revokeAuthToken(context, storage, token), + remoteTokens, + allocateRemoteToken(dockId, origin, originLock) { + const token = humanId() + remoteTokens.set(token, { dockId, origin, originLock }) + return token + }, + revokeRemoteToken, + revokeRemoteTokensForDock(dockId) { + const tokensToRevoke: string[] = [] + for (const [token, record] of remoteTokens) { + if (record.dockId === dockId) + tokensToRevoke.push(token) + } + for (const token of tokensToRevoke) + revokeRemoteToken(token) + }, + isRemoteTokenTrusted(token, requestOrigin) { + const record = remoteTokens.get(token) + if (!record) + return false + if (!record.originLock) + return true + return !!requestOrigin && record.origin === requestOrigin + }, + } + internalContextMap.set(context, internalContext) + } + return internalContextMap.get(context)! +} diff --git a/packages/devframe/src/node/internal/index.ts b/packages/devframe/src/node/internal/index.ts new file mode 100644 index 0000000..e3c0234 --- /dev/null +++ b/packages/devframe/src/node/internal/index.ts @@ -0,0 +1,26 @@ +/** + * Reserved for `@vitejs/devtools-kit` and other first-party adapters + * that reach into devframe's private machinery (currently the + * remote-dock token bridge required by the relocated `DocksHost`). + * + * End users should not import from this subpath. The surface is + * unstable and may change without a major bump. + * + * @internal + */ + +export { + normalizeBasePath, + resolveBasePath, +} from '../../adapters/_shared' + +export { + getInternalContext, + internalContextMap, +} from './context' + +export type { + DevToolsInternalContext, + InternalAnonymousAuthStorage, + RemoteTokenRecord, +} from './context' diff --git a/packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts new file mode 100644 index 0000000..377a0f1 --- /dev/null +++ b/packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts @@ -0,0 +1,131 @@ +import type { DevToolsHost } from '../../../types/host' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { describe, expect, it } from 'vitest' +import { createHostContext } from '../../context' +import { buildMcpServerFromContext } from '../build-server' + +function nullHost(): DevToolsHost { + return { + mountStatic: () => { /* no-op */ }, + resolveOrigin: () => 'mcp://test', + getStorageDir: () => '/tmp/devframe-test-storage', + } +} + +async function bootPair() { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: 'test', + serverVersion: '0.0.0-test', + exposeSharedState: true, + }) + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await client.connect(clientTransport) + + return { + ctx, + client, + cleanup: async () => { + dispose() + await client.close() + await server.close() + }, + } +} + +describe('mcp adapter (in-memory)', () => { + it('lists tools registered via ctx.agent.registerTool', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'greet', + description: 'Say hello.', + safety: 'read', + handler: () => ({ greeting: 'hi' }), + }) + + const result = await client.listTools() + expect(result.tools.map(t => t.name)).toContain('greet') + const tool = result.tools.find(t => t.name === 'greet')! + expect(tool.description).toBe('Say hello.') + expect(tool.annotations?.readOnlyHint).toBe(true) + } + finally { + await cleanup() + } + }) + + it('calls a tool and returns the text content', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'echo', + description: 'Echo.', + handler: args => ({ echoed: args }), + }) + + const result = await client.callTool({ name: 'echo', arguments: { foo: 'bar' } }) + const content = result.content as Array<{ type: string, text: string }> + expect(content[0]!.type).toBe('text') + expect(JSON.parse(content[0]!.text)).toEqual({ echoed: { foo: 'bar' } }) + } + finally { + await cleanup() + } + }) + + it('lists and reads registered resources', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerResource({ + id: 'build-status', + name: 'Build status', + description: 'Current build status.', + read: () => ({ json: { status: 'ok' } }), + }) + + const listed = await client.listResources() + const resource = listed.resources.find(r => r.uri === 'devframe://resource/build-status') + expect(resource).toBeDefined() + expect(resource!.name).toBe('Build status') + + const read = await client.readResource({ uri: 'devframe://resource/build-status' }) + const c = read.contents[0] as { text: string, mimeType?: string } + expect(c.mimeType).toBe('application/json') + expect(JSON.parse(c.text)).toEqual({ status: 'ok' }) + } + finally { + await cleanup() + } + }) + + it('surfaces shared-state keys as MCP resources', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const state = await ctx.rpc.sharedState.get('my-plugin:counter' as any, { + initialValue: { count: 7 }, + }) + + const listed = await client.listResources() + const key = 'my-plugin:counter' + const encoded = encodeURIComponent(key) + const resource = listed.resources.find(r => r.uri === `devframe://state/${encoded}`) + expect(resource).toBeDefined() + + const read = await client.readResource({ uri: `devframe://state/${encoded}` }) + const c = read.contents[0] as { text: string } + expect(JSON.parse(c.text)).toEqual({ count: 7 }) + // Satisfy linter by touching the state handle. + expect(state.value()).toEqual({ count: 7 }) + } + finally { + await cleanup() + } + }) +}) diff --git a/packages/devframe/src/node/mcp/__tests__/to-json-schema.test.ts b/packages/devframe/src/node/mcp/__tests__/to-json-schema.test.ts new file mode 100644 index 0000000..b275ec5 --- /dev/null +++ b/packages/devframe/src/node/mcp/__tests__/to-json-schema.test.ts @@ -0,0 +1,53 @@ +import * as v from 'valibot' +import { describe, expect, it } from 'vitest' +import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from '../to-json-schema' + +describe('valibotArgsToJsonSchema', () => { + it('returns an empty object schema when no args', () => { + const { schema, unwrapped } = valibotArgsToJsonSchema(undefined) + expect(unwrapped).toBe(false) + expect(schema).toEqual({ type: 'object', properties: {} }) + }) + + it('wraps multiple positional args under arg0/arg1/...', () => { + const { schema, unwrapped } = valibotArgsToJsonSchema([v.string(), v.number()]) + expect(unwrapped).toBe(false) + expect(schema).toMatchObject({ + type: 'object', + required: ['arg0', 'arg1'], + additionalProperties: false, + }) + const props = (schema as any).properties + expect(props.arg0).toMatchObject({ type: 'string' }) + expect(props.arg1).toMatchObject({ type: 'number' }) + }) + + it('unwraps a single object schema for nicer agent UX', () => { + const { schema, unwrapped } = valibotArgsToJsonSchema([ + v.object({ name: v.string(), age: v.number() }), + ]) + expect(unwrapped).toBe(true) + expect((schema as any).type).toBe('object') + const props = (schema as any).properties + expect(props.name).toBeDefined() + expect(props.age).toBeDefined() + }) + + it('keeps arg0 shape when the single arg is a primitive', () => { + const { schema, unwrapped } = valibotArgsToJsonSchema([v.string()]) + expect(unwrapped).toBe(false) + expect(schema).toMatchObject({ type: 'object', required: ['arg0'] }) + }) +}) + +describe('valibotReturnToJsonSchema', () => { + it('returns undefined when no schema is provided', () => { + expect(valibotReturnToJsonSchema(undefined)).toBeUndefined() + }) + + it('converts a simple schema', () => { + const schema = valibotReturnToJsonSchema(v.object({ ok: v.boolean() })) + expect((schema as any).type).toBe('object') + expect((schema as any).properties.ok).toMatchObject({ type: 'boolean' }) + }) +}) diff --git a/packages/devframe/src/node/mcp/build-server.ts b/packages/devframe/src/node/mcp/build-server.ts new file mode 100644 index 0000000..e946fb0 --- /dev/null +++ b/packages/devframe/src/node/mcp/build-server.ts @@ -0,0 +1,302 @@ +import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc' +import type { AgentTool, DevframeDefinition, DevToolsHost, DevToolsNodeContext } from 'devframe/types' +import type { GenericSchema } from 'valibot' +import { homedir } from 'node:os' +import process from 'node:process' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' +import { join } from 'pathe' +import { createHostContext } from '../context' +import { logger } from '../diagnostics' +import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-schema' + +export interface CreateMcpServerOptions { + /** + * Transport to use. Only `'stdio'` is implemented today; HTTP support + * is planned in a follow-up PR. + */ + transport?: 'stdio' + /** + * Expose shared-state keys as MCP resources. + * - `true` (default) — every key the host publishes + * - `false` — none + * - `(key) => boolean` — filter + */ + exposeSharedState?: boolean | ((key: string) => boolean) + /** Override the name reported in the MCP handshake. */ + serverName?: string + /** Override the version reported in the MCP handshake. Defaults to `definition.version ?? '0.0.0'`. */ + serverVersion?: string + /** Called once the transport is connected. */ + onReady?: (info: { transport: 'stdio' }) => void +} + +export interface McpServerHandle { + stop: () => Promise +} + +/** + * Wire an MCP {@link Server} to a devframe context. Returns the server + * plus a disposal function for the subscriptions it sets up. The + * transport is the caller's responsibility — `createMcpServer` connects + * stdio; tests can connect an {@link InMemoryTransport} instead. + * + * @internal + */ +export function buildMcpServerFromContext( + ctx: DevToolsNodeContext, + options: { serverName: string, serverVersion: string, exposeSharedState: boolean | ((k: string) => boolean) }, +): { server: Server, dispose: () => void } { + const server = new Server( + { + name: options.serverName, + version: options.serverVersion, + }, + { + capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true }, + }, + }, + ) + + registerToolHandlers(server, ctx) + registerResourceHandlers(server, ctx, options.exposeSharedState) + + const notify = (method: string): void => { + server.notification({ method }).catch(() => { /* ignore transport errors */ }) + } + const offManifest = ctx.agent.events.on('agent:manifest:changed', () => { + notify('notifications/tools/list_changed') + notify('notifications/resources/list_changed') + }) + const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => { + notify('notifications/resources/list_changed') + }) + + return { + server, + dispose: () => { + offManifest() + offKeyAdded() + }, + } +} + +/** + * Build an MCP server over the agent surface of a devframe definition. + * Currently supports `stdio` transport only. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export async function createMcpServer( + definition: DevframeDefinition, + options: CreateMcpServerOptions = {}, +): Promise { + const transport = options.transport ?? 'stdio' + if (transport !== 'stdio') + throw logger.DF0017({ transport, reason: 'Only stdio transport is supported in this release.' }).throw() + + const host: DevToolsHost = { + mountStatic: () => { /* MCP has no static surface */ }, + resolveOrigin: () => 'mcp://devframe', + getStorageDir: scope => scope === 'workspace' + ? join(process.cwd(), `node_modules/.${definition.id}/devtools`) + : join(homedir(), `.${definition.id}/devtools`), + } + + const ctx = await createHostContext({ + cwd: process.cwd(), + mode: 'dev', + host, + }) + await definition.setup(ctx) + + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: options.serverName ?? `${definition.id} (devframe)`, + serverVersion: options.serverVersion ?? definition.version ?? '0.0.0', + exposeSharedState: options.exposeSharedState ?? true, + }) + + const { startStdioTransport } = await import('./transports') + let stop: () => Promise + try { + stop = await startStdioTransport(server) + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw logger.DF0017({ transport, reason }, { cause: error }).throw() + } + + options.onReady?.({ transport: 'stdio' }) + + return { + async stop() { + dispose() + await stop() + }, + } +} + +function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void { + server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx)) + return { tools } + }) + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + try { + const result = await ctx.agent.invoke(name, args ?? {}) + return { + content: [ + { + type: 'text', + text: stringify(result), + }, + ], + } + } + catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Error invoking "${name}": ${error instanceof Error ? error.message : String(error)}`, + }, + ], + } + } + }) +} + +function registerResourceHandlers( + server: Server, + ctx: DevToolsNodeContext, + exposeSharedState: boolean | ((key: string) => boolean), +): void { + server.setRequestHandler(ListResourcesRequestSchema, async () => { + const resources = ctx.agent.list().resources.map(resource => ({ + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + })) + + if (exposeSharedState !== false) { + const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true + for (const key of ctx.rpc.sharedState.keys()) { + if (!filter(key)) + continue + resources.push({ + uri: `devframe://state/${encodeURIComponent(key)}`, + name: key, + description: `Shared state: ${key}`, + mimeType: 'application/json', + }) + } + } + + return { resources } + }) + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params + const parsed = parseResourceUri(uri) + + if (parsed.kind === 'resource') { + const content = await ctx.agent.read(parsed.id) + return { + contents: [ + { + uri, + mimeType: content.mimeType ?? 'application/json', + text: content.text ?? stringify(content.json), + }, + ], + } + } + + if (parsed.kind === 'state') { + const state = await ctx.rpc.sharedState.get(parsed.key as any) + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: stringify(state.value()), + }, + ], + } + } + + throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`) + }) +} + +function projectTool(tool: AgentTool, ctx: DevToolsNodeContext): Record { + const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx) + const outputSchema = tool.outputSchema ?? computeOutputSchema(tool, ctx) + return { + name: tool.id, + title: tool.title, + description: tool.description, + inputSchema, + ...(outputSchema ? { outputSchema } : {}), + annotations: { + title: tool.title, + readOnlyHint: tool.safety === 'read', + destructiveHint: tool.safety === 'destructive', + }, + } +} + +function computeInputSchema(tool: AgentTool, ctx: DevToolsNodeContext): unknown { + if (tool.kind !== 'rpc' || !tool.rpcName) + return { type: 'object', properties: {} } + const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | undefined + if (!def) + return { type: 'object', properties: {} } + const args = def.args as readonly GenericSchema[] | undefined + return valibotArgsToJsonSchema(args).schema +} + +function computeOutputSchema(tool: AgentTool, ctx: DevToolsNodeContext): unknown { + if (tool.kind !== 'rpc' || !tool.rpcName) + return undefined + const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | undefined + if (!def) + return undefined + return valibotReturnToJsonSchema(def.returns as GenericSchema | undefined) +} + +function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kind: 'state', key: string } | { kind: 'unknown' } { + const match = uri.match(/^devframe:\/\/(resource|state)\/(.+)$/) + if (!match) + return { kind: 'unknown' } + const [, kind, rest] = match + const decoded = decodeURIComponent(rest!) + if (kind === 'resource') + return { kind: 'resource', id: decoded } + return { kind: 'state', key: decoded } +} + +function stringify(value: unknown): string { + if (value === undefined) + return 'undefined' + if (typeof value === 'string') + return value + try { + return JSON.stringify(value, null, 2) + } + catch { + return String(value) + } +} diff --git a/packages/devframe/src/node/mcp/to-json-schema.ts b/packages/devframe/src/node/mcp/to-json-schema.ts new file mode 100644 index 0000000..ccf1b14 --- /dev/null +++ b/packages/devframe/src/node/mcp/to-json-schema.ts @@ -0,0 +1,81 @@ +import type { GenericSchema } from 'valibot' +import { toJsonSchema } from '@valibot/to-json-schema' + +const FALLBACK_OBJECT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: true }) + +/** + * Convert a valibot return schema to JSON Schema. + * @internal + */ +export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown { + if (!schema) + return undefined + try { + return toJsonSchema(schema as any) + } + catch { + return FALLBACK_OBJECT_SCHEMA + } +} + +/** + * Convert positional RPC args schemas to a single MCP-friendly object + * schema. When the RPC declares `args: [v.object(...)]`, unwrap the + * single-object schema directly (nicer agent UX than `{ arg0: {...} }`). + * + * Returns `undefined` when there are no args (the MCP SDK treats this + * as `{ type: 'object', properties: {} }`). + * @internal + */ +export function valibotArgsToJsonSchema( + args: readonly GenericSchema[] | undefined, +): { schema: unknown, unwrapped: boolean } { + if (!args || args.length === 0) + return { schema: { type: 'object', properties: {} }, unwrapped: false } + + // Single-object arg: unwrap. + if (args.length === 1) { + const inner = safeToJsonSchema(args[0]!) + if (isObjectJsonSchema(inner)) + return { schema: inner, unwrapped: true } + // Non-object single arg (e.g. a string): fall through to arg0 shape. + } + + const properties: Record = {} + const required: string[] = [] + for (let i = 0; i < args.length; i++) { + const key = `arg${i}` + const s = safeToJsonSchema(args[i]!) + properties[key] = s + // Conservatively mark every positional arg as required — the RPC + // layer validates against valibot anyway. + required.push(key) + } + + return { + schema: { + type: 'object', + properties, + required, + additionalProperties: false, + }, + unwrapped: false, + } +} + +function safeToJsonSchema(schema: GenericSchema): unknown { + try { + return toJsonSchema(schema as any) + } + catch { + return FALLBACK_OBJECT_SCHEMA + } +} + +function isObjectJsonSchema(value: unknown): boolean { + return ( + !!value + && typeof value === 'object' + && (value as { type?: unknown }).type === 'object' + ) +} diff --git a/packages/devframe/src/node/mcp/transports.ts b/packages/devframe/src/node/mcp/transports.ts new file mode 100644 index 0000000..5fa5f55 --- /dev/null +++ b/packages/devframe/src/node/mcp/transports.ts @@ -0,0 +1,14 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' + +/** + * Start the MCP server on stdio. Returns a stop function. + * @internal + */ +export async function startStdioTransport(server: Server): Promise<() => Promise> { + const transport = new StdioServerTransport() + await server.connect(transport) + return async () => { + await server.close() + } +} diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts new file mode 100644 index 0000000..ff4bf30 --- /dev/null +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -0,0 +1,133 @@ +import type { DevToolsRpcSharedStates, RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' +import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' +import { createSharedState } from 'devframe/utils/shared-state' +import { createDebug } from 'obug' +import { logger } from './diagnostics' + +const debug = createDebug('vite:devtools:rpc:state:changed') +const debugSubscribe = createDebug('vite:devtools:rpc:state:subscribe') + +export function createRpcSharedStateServerHost( + rpc: RpcFunctionsHost, +): RpcSharedStateHost { + const sharedState = new Map>() + const keyAddedListeners = new Set<(key: string) => void>() + + function registerSharedState(key: string, state: SharedState) { + const offs: (() => void)[] = [] + + offs.push( + state.on('updated', (fullState, patches, syncId) => { + if (patches) { + debug('patch', { key, syncId }) + rpc.broadcast({ + method: 'devframe:rpc:client-state:patch', + args: [key, patches, syncId], + filter: client => client.$meta.subscribedStates.has(key), + }) + } + else { + debug('updated', { key, syncId }) + rpc.broadcast({ + method: 'devframe:rpc:client-state:updated', + args: [key, fullState, syncId], + filter: client => client.$meta.subscribedStates.has(key), + }) + } + }), + ) + + return () => { + for (const off of offs) { + off() + } + } + } + + const host: RpcSharedStateHost = { + get: async (key: string, options?: RpcSharedStateGetOptions) => { + if (sharedState.has(key)) { + return sharedState.get(key)! + } + if (options?.initialValue === undefined && options?.sharedState === undefined) { + throw logger.DF0013({ key }).throw() + } + debug('new-state', key) + const state = options.sharedState ?? createSharedState({ + initialValue: options.initialValue as T, + enablePatches: false, + }) + registerSharedState(key, state) + sharedState.set(key, state) + for (const fn of keyAddedListeners) + fn(key) + return state + }, + keys() { + return Array.from(sharedState.keys()) + }, + onKeyAdded(fn) { + keyAddedListeners.add(fn) + return () => { + keyAddedListeners.delete(fn) + } + }, + } + + // Wire methods that the client-side `client/rpc-shared-state.ts` + // calls to subscribe / get / push patches / push full snapshots. + // Registering them here keeps shared-state self-contained: any + // server built on `RpcFunctionsHost` (devframe standalone or kit / + // core) gets the full sync protocol out of the box. + rpc.register({ + name: 'devframe:rpc:server-state:subscribe', + type: 'event', + handler(key: string) { + const session = rpc.getCurrentRpcSession() + if (!session) + return + debugSubscribe('subscribe', { key, session: session.meta.id }) + session.meta.subscribedStates.add(key) + }, + }) + + rpc.register({ + name: 'devframe:rpc:server-state:get', + type: 'query', + handler: async (key: string) => { + if (!sharedState.has(key)) + return undefined + const state = await host.get(key as keyof DevToolsRpcSharedStates) + return state.value() + }, + // Pre-compute snapshots for the build-mode static dump so the SPA + // can read them without a live server. + dump: () => ({ + inputs: host.keys().map(key => [key] as [string]), + }), + }) + + rpc.register({ + name: 'devframe:rpc:server-state:set', + type: 'query', + handler: async (key: string, value: any, syncId: string) => { + const state = await host.get(key as keyof DevToolsRpcSharedStates, { + initialValue: value, + }) + state.mutate(() => value, syncId) + }, + }) + + rpc.register({ + name: 'devframe:rpc:server-state:patch', + type: 'query', + handler: async (key: string, patches: SharedStatePatch[], syncId: string) => { + if (!sharedState.has(key)) + return + const state = await host.get(key as keyof DevToolsRpcSharedStates) + state.patch(patches, syncId) + }, + }) + + return host +} diff --git a/packages/devframe/src/node/rpc-streaming.ts b/packages/devframe/src/node/rpc-streaming.ts new file mode 100644 index 0000000..220c2a1 --- /dev/null +++ b/packages/devframe/src/node/rpc-streaming.ts @@ -0,0 +1,378 @@ +import type { + DevToolsNodeRpcSessionMeta, + RpcFunctionsHost, + RpcStreamingChannel, + RpcStreamingChannelOptions, + RpcStreamingHost, +} from 'devframe/types' +import type { StreamErrorPayload, StreamReader, StreamSink } from 'devframe/utils/streaming-channel' +import { createStreamReader, createStreamSink } from 'devframe/utils/streaming-channel' +import { createDebug } from 'obug' +import { logger } from './diagnostics' + +const debug = createDebug('vite:devtools:rpc:streaming') + +const STREAM_KEY_SEPARATOR = '\x1F' + +function streamKey(channel: string, id: string): string { + return `${channel}${STREAM_KEY_SEPARATOR}${id}` +} + +interface ServerStreamRecord { + sink: StreamSink + subscribers: Set + unbinders: (() => void)[] + /** Timer scheduled when stream closes with no subscribers; cleared on resubscribe. */ + retentionTimer?: ReturnType +} + +interface ServerInboundRecord { + reader: StreamReader + /** First session that wrote to this inbound — locks ownership for cleanup. */ + uploaderMeta?: DevToolsNodeRpcSessionMeta +} + +interface ChannelState { + name: string + options: Required + streams: Map> + inbound: Map> +} + +/** + * Build the server-side streaming host. Mirrors the layout of + * `createRpcSharedStateServerHost` — registers a fixed set of internal + * RPC methods (`subscribe` / `unsubscribe` / `cancel`) once, then per-channel + * state lives in a `Map`. + */ +export function createRpcStreamingServerHost(rpc: RpcFunctionsHost): RpcStreamingHost { + const channels = new Map() + + function findStream(channelName: string, id: string): ServerStreamRecord | undefined { + return channels.get(channelName)?.streams.get(id) + } + + function freeStreamNow(state: ChannelState, id: string): void { + const record = state.streams.get(id) + if (!record) + return + if (record.retentionTimer) { + clearTimeout(record.retentionTimer) + record.retentionTimer = undefined + } + for (const off of record.unbinders) off() + state.streams.delete(id) + debug('freed', state.name, id) + } + + function maybeFreeStream(state: ChannelState, id: string): void { + const record = state.streams.get(id) + if (!record) + return + if (!record.sink.closed || record.subscribers.size > 0) + return + + // Closed and no subscribers — either free now or hold for replay. + const retention = state.options.closedStreamRetention + if (retention <= 0) { + freeStreamNow(state, id) + return + } + // Schedule free unless a subscriber resurrects the stream first. + if (record.retentionTimer) + return // already scheduled + record.retentionTimer = setTimeout(freeStreamNow, retention, state, id) + } + + function cancelRetention(record: ServerStreamRecord): void { + if (record.retentionTimer) { + clearTimeout(record.retentionTimer) + record.retentionTimer = undefined + } + } + + rpc.register({ + name: 'devframe:streaming:subscribe', + type: 'event', + handler(channelName: string, id: string, opts?: { afterSeq?: number }) { + const state = channels.get(channelName) + if (!state) { + logger.DF0030({ channel: channelName, id }).log() + return + } + const record = state.streams.get(id) + if (!record) { + logger.DF0030({ channel: channelName, id }).log() + return + } + const session = rpc.getCurrentRpcSession() + if (!session) + return + const key = streamKey(channelName, id) + session.meta.subscribedStreams ??= new Set() + session.meta.subscribedStreams.add(key) + record.subscribers.add(session.meta) + cancelRetention(record) + + const afterSeq = opts?.afterSeq ?? 0 + for (const buffered of record.sink.buffer) { + if (buffered.seq > afterSeq) { + rpc.broadcast({ + method: 'devframe:streaming:chunk', + args: [channelName, id, buffered.seq, buffered.chunk], + event: true, + optional: true, + filter: client => client.$meta === session.meta, + }) + } + } + if (record.sink.closed) { + rpc.broadcast({ + method: 'devframe:streaming:end', + args: [channelName, id, undefined], + event: true, + optional: true, + filter: client => client.$meta === session.meta, + }) + } + }, + }) + + rpc.register({ + name: 'devframe:streaming:unsubscribe', + type: 'event', + handler(channelName: string, id: string) { + const state = channels.get(channelName) + const record = state?.streams.get(id) + const session = rpc.getCurrentRpcSession() + if (!session) + return + session.meta.subscribedStreams?.delete(streamKey(channelName, id)) + if (state && record) { + record.subscribers.delete(session.meta) + maybeFreeStream(state, id) + } + }, + }) + + rpc.register({ + name: 'devframe:streaming:cancel', + type: 'event', + handler(channelName: string, id: string) { + const record = findStream(channelName, id) + if (!record) + return + // Cooperative cancel — only abort if the cancelling session was the + // last subscriber. Otherwise other clients still want the stream. + const session = rpc.getCurrentRpcSession() + if (!session) + return + record.subscribers.delete(session.meta) + session.meta.subscribedStreams?.delete(streamKey(channelName, id)) + if (record.subscribers.size === 0) + record.sink.abort('cancelled by client') + }, + }) + + rpc.register({ + name: 'devframe:streaming:upload-chunk', + type: 'event', + handler(channelName: string, id: string, seq: number, chunk: any) { + const state = channels.get(channelName) + const record = state?.inbound.get(id) + if (!record) { + logger.DF0030({ channel: channelName, id }).log() + return + } + // Lock the inbound to the first session that writes; subsequent + // chunks from a different session are ignored. The action handler + // returned the id to one specific caller, so this is the + // expected ownership model. + if (!record.uploaderMeta) { + const session = rpc.getCurrentRpcSession() + if (session) { + record.uploaderMeta = session.meta + session.meta.uploadingStreams ??= new Set() + session.meta.uploadingStreams.add(streamKey(channelName, id)) + } + } + record.reader._push(seq, chunk) + }, + }) + + rpc.register({ + name: 'devframe:streaming:upload-end', + type: 'event', + handler(channelName: string, id: string, error?: StreamErrorPayload) { + const state = channels.get(channelName) + const record = state?.inbound.get(id) + if (!record) + return + record.reader._end(error) + if (record.uploaderMeta) { + record.uploaderMeta.uploadingStreams?.delete(streamKey(channelName, id)) + } + state?.inbound.delete(id) + }, + }) + + function createChannel(name: string, opts: RpcStreamingChannelOptions = {}): RpcStreamingChannel { + if (channels.has(name)) + throw logger.DF0032({ channel: name }).throw() + + const replayWindow = opts.replayWindow ?? 0 + const state: ChannelState = { + name, + options: { + replayWindow, + // Default to a 30-second hold when replay is enabled so late + // subscribers can join after the producer finishes. + closedStreamRetention: opts.closedStreamRetention ?? (replayWindow > 0 ? 30_000 : 0), + }, + streams: new Map(), + inbound: new Map(), + } + channels.set(name, state) + + function start(startOpts: { id?: string } = {}): StreamSink { + const sink = createStreamSink({ + id: startOpts.id, + replayWindow: state.options.replayWindow, + }) + + const record: ServerStreamRecord = { + sink, + subscribers: new Set(), + unbinders: [], + } + state.streams.set(sink.id, record) + + record.unbinders.push( + sink.events.on('chunk', (seq, chunk) => { + rpc.broadcast({ + method: 'devframe:streaming:chunk', + args: [name, sink.id, seq, chunk], + event: true, + optional: true, + filter: client => record.subscribers.has(client.$meta as DevToolsNodeRpcSessionMeta), + }) + }), + ) + record.unbinders.push( + sink.events.on('end', (error) => { + rpc.broadcast({ + method: 'devframe:streaming:end', + args: [name, sink.id, error], + event: true, + optional: true, + filter: client => record.subscribers.has(client.$meta as DevToolsNodeRpcSessionMeta), + }) + maybeFreeStream(state, sink.id) + }), + ) + + return sink + } + + async function pipeFrom(readable: ReadableStream, startOpts: { id?: string } = {}): Promise> { + const sink = start(startOpts) + readable.pipeTo(sink.writable, { signal: sink.signal }).catch(() => { + // Errors flow through the writable's `abort` → `sink.error`. + // The pipeTo rejection is informational only. + }) + return sink + } + + function get(id: string): StreamSink | undefined { + return state.streams.get(id)?.sink + } + + function ids(): string[] { + return Array.from(state.streams.keys()) + } + + function openInbound(inboundOpts: { id?: string } = {}): StreamReader { + // Forward-declared so `onCancel` can read the uploader meta that's + // assigned later (when the first chunk arrives). + let inboundRecord: ServerInboundRecord + const reader = createStreamReader({ + id: inboundOpts.id, + onCancel() { + // Server-initiated cancel — tell the uploading client to stop. + // The cancel is targeted at the session that owns this inbound. + const targetMeta = inboundRecord?.uploaderMeta + if (!targetMeta) + return + rpc.broadcast({ + method: 'devframe:streaming:upload-cancel', + args: [name, reader.id], + event: true, + optional: true, + filter: client => client.$meta === targetMeta, + }) + }, + }) + + inboundRecord = { reader } + state.inbound.set(reader.id, inboundRecord) + debug('opened-inbound', name, reader.id) + + return reader + } + + return { name, start, pipeFrom, get, ids, openInbound } + } + + function parseKey(key: string): { channelName: string, id: string } | undefined { + const sepIdx = key.indexOf(STREAM_KEY_SEPARATOR) + if (sepIdx < 0) + return undefined + return { channelName: key.slice(0, sepIdx), id: key.slice(sepIdx + 1) } + } + + return { + create: createChannel, + _onSessionDisconnected(meta: DevToolsNodeRpcSessionMeta) { + // Outbound: drop subscriber, abort if last one drops. + if (meta.subscribedStreams) { + for (const key of meta.subscribedStreams) { + const parsed = parseKey(key) + if (!parsed) + continue + const state = channels.get(parsed.channelName) + const record = state?.streams.get(parsed.id) + if (!state || !record) + continue + record.subscribers.delete(meta) + if (record.subscribers.size === 0 && !record.sink.closed) { + // Last subscriber gone — abort so the producer can short-circuit. + record.sink.abort('all subscribers disconnected') + } + maybeFreeStream(state, parsed.id) + } + meta.subscribedStreams.clear() + } + + // Inbound: end the reader with an error so the consuming handler + // exits cleanly. Each inbound is owned by one session so we just + // free it. + if (meta.uploadingStreams) { + for (const key of meta.uploadingStreams) { + const parsed = parseKey(key) + if (!parsed) + continue + const state = channels.get(parsed.channelName) + const record = state?.inbound.get(parsed.id) + if (!state || !record) + continue + record.reader._end({ + name: 'UploadDisconnected', + message: 'Uploader disconnected before completing the stream', + }) + state.inbound.delete(parsed.id) + } + meta.uploadingStreams.clear() + } + }, + } +} diff --git a/packages/devframe/src/node/rpc/agent-invoke-tool.ts b/packages/devframe/src/node/rpc/agent-invoke-tool.ts new file mode 100644 index 0000000..1ff531a --- /dev/null +++ b/packages/devframe/src/node/rpc/agent-invoke-tool.ts @@ -0,0 +1,13 @@ +import { defineRpcFunction } from 'devframe' + +export const agentInvokeTool = defineRpcFunction({ + name: 'devframe:agent:invoke-tool', + type: 'action', + setup: (ctx) => { + return { + async handler(id: string, args: unknown): Promise { + return await ctx.agent.invoke(id, args) + }, + } + }, +}) diff --git a/packages/devframe/src/node/rpc/agent-list-resources.ts b/packages/devframe/src/node/rpc/agent-list-resources.ts new file mode 100644 index 0000000..de0d8ec --- /dev/null +++ b/packages/devframe/src/node/rpc/agent-list-resources.ts @@ -0,0 +1,15 @@ +import type { AgentResource } from 'devframe/types' +import { defineRpcFunction } from 'devframe' + +export const agentListResources = defineRpcFunction({ + name: 'devframe:agent:list-resources', + type: 'query', + jsonSerializable: true, + setup: (ctx) => { + return { + async handler(): Promise { + return ctx.agent.list().resources + }, + } + }, +}) diff --git a/packages/devframe/src/node/rpc/agent-list-tools.ts b/packages/devframe/src/node/rpc/agent-list-tools.ts new file mode 100644 index 0000000..280b645 --- /dev/null +++ b/packages/devframe/src/node/rpc/agent-list-tools.ts @@ -0,0 +1,15 @@ +import type { AgentTool } from 'devframe/types' +import { defineRpcFunction } from 'devframe' + +export const agentListTools = defineRpcFunction({ + name: 'devframe:agent:list-tools', + type: 'query', + jsonSerializable: true, + setup: (ctx) => { + return { + async handler(): Promise { + return ctx.agent.list().tools + }, + } + }, +}) diff --git a/packages/devframe/src/node/rpc/agent-read-resource.ts b/packages/devframe/src/node/rpc/agent-read-resource.ts new file mode 100644 index 0000000..62e85dc --- /dev/null +++ b/packages/devframe/src/node/rpc/agent-read-resource.ts @@ -0,0 +1,15 @@ +import type { AgentResourceContent } from 'devframe/types' +import { defineRpcFunction } from 'devframe' + +export const agentReadResource = defineRpcFunction({ + name: 'devframe:agent:read-resource', + type: 'query', + jsonSerializable: true, + setup: (ctx) => { + return { + async handler(id: string): Promise { + return await ctx.agent.read(id) + }, + } + }, +}) diff --git a/packages/devframe/src/node/rpc/index.ts b/packages/devframe/src/node/rpc/index.ts new file mode 100644 index 0000000..7fd3f00 --- /dev/null +++ b/packages/devframe/src/node/rpc/index.ts @@ -0,0 +1,29 @@ +import { agentInvokeTool } from './agent-invoke-tool' +import { agentListResources } from './agent-list-resources' +import { agentListTools } from './agent-list-tools' +import { agentReadResource } from './agent-read-resource' + +export { agentInvokeTool, agentListResources, agentListTools, agentReadResource } + +/** + * Built-in agent introspection RPC functions. Registered automatically + * by `createHostContext`. Not themselves agent-exposed (no `agent` + * field) — they power the MCP adapter and any future agent CLI. + * + * @experimental + */ +export const BUILTIN_AGENT_RPC = [ + agentListTools, + agentInvokeTool, + agentListResources, + agentReadResource, +] as const + +declare module 'devframe/types' { + interface DevToolsRpcServerFunctions { + 'devframe:agent:list-tools': () => Promise + 'devframe:agent:invoke-tool': (id: string, args: unknown) => Promise + 'devframe:agent:list-resources': () => Promise + 'devframe:agent:read-resource': (id: string) => Promise + } +} diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts new file mode 100644 index 0000000..3f40c11 --- /dev/null +++ b/packages/devframe/src/node/server.ts @@ -0,0 +1,150 @@ +import type { BirpcGroup } from 'birpc' +import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions } from 'devframe/types' +import type { App } from 'h3' +import type { WebSocketServer } from 'ws' +import type { RpcFunctionsHost } from './host-functions' +import { AsyncLocalStorage } from 'node:async_hooks' +import { createServer } from 'node:http' +import { createRpcServer } from 'devframe/rpc/server' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' +import { createApp, toNodeListener } from 'h3' +import { WebSocketServer as WSServer } from 'ws' + +export interface StartHttpAndWsOptions { + context: DevToolsNodeContext + host?: string + port: number + /** + * Optional h3 app to mount on. When omitted a fresh one is created; + * when provided, callers can add their own routes (static handlers, + * auth middleware, etc.) first. + */ + app?: App + /** + * When `false`, the RPC server is started without a trust handshake. + * Intended for single-user localhost tools where an auth round-trip + * would only get in the way. The Vite-flavoured auth layer in + * `@vitejs/devtools` already honors the equivalent + * `devtools.clientAuth` setting; devframe records the intent here so + * future auth plumbing can consult it without another API change. + * + * Default: `true`. + */ + auth?: boolean + /** + * Called once the WS server is bound so callers can mount static + * handlers whose origin depends on the resolved port, or print their + * own startup banner. Devframe does not print one itself. + */ + onReady?: (info: { origin: string, port: number, app: App }) => void | Promise +} + +export interface StartedServer { + /** Listening origin, e.g. `http://localhost:9999`. */ + origin: string + port: number + app: App + wss: WebSocketServer + rpcGroup: BirpcGroup + close: () => Promise +} + +/** + * Compose an h3 + WebSocket server for a devframe context. The RPC + * group is bound to `context.rpc.functions`; the WS endpoint lives on + * the same port as the HTTP server. + */ +export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { + const { context, port } = options + const bindHost = options.host ?? 'localhost' + const app = options.app ?? createApp() + const httpServer = createServer(toNodeListener(app)) + const wss = new WSServer({ server: httpServer }) + const rpcHost = context.rpc as unknown as RpcFunctionsHost + + const asyncStorage = new AsyncLocalStorage() + + const rpcGroup = createRpcServer( + rpcHost.functions, + { + rpcOptions: { + // Wrap each RPC handler in an AsyncLocalStorage context so + // `ctx.rpc.getCurrentRpcSession()` works inside handlers (used + // by streaming subscribe/unsubscribe/cancel and shared-state + // sync). Mirrors `packages/core/src/node/ws.ts`'s resolver, + // minus the auth gate (devframe defers auth to its host + // adapters; the standalone CLI server is unauthenticated). + resolver(name, fn) { + // eslint-disable-next-line ts/no-this-alias + const rpc = this + if (!fn) + return undefined + return async function (this: any, ...args) { + return await asyncStorage.run({ + rpc, + meta: rpc.$meta, + }, async () => { + return (await fn).apply(this, args) + }) + } + }, + }, + }, + ) + + attachWsRpcTransport(rpcGroup, { + wss, + onDisconnected: (_ws, meta) => { + rpcHost._emitSessionDisconnected(meta) + }, + }) + + ;(rpcHost as any)._rpcGroup = rpcGroup + ;(rpcHost as any)._asyncStorage = asyncStorage + ;(rpcHost as any)._authDisabled = options.auth === false + + // The browser client unconditionally calls `vite:anonymous:auth` on + // connect (see `client/rpc-ws.ts`). When `auth: false` is set on the + // standalone server, register a noop handler that auto-trusts so the + // client's hardcoded handshake succeeds. The Vite-side adapter + // registers the real handler with the same name; the two paths never + // overlap because Vite consumers never opt into `auth: false`. + if (options.auth === false && !rpcHost.definitions.has('vite:anonymous:auth')) { + rpcHost.register({ + name: 'vite:anonymous:auth', + type: 'action', + handler: () => { + const session = rpcHost.getCurrentRpcSession() + if (session) + session.meta.isTrusted = true + return { isTrusted: true } + }, + }) + } + + await new Promise((resolveListen) => { + httpServer.listen(port, bindHost, () => resolveListen()) + }) + + const origin = `http://${bindHost}:${port}` + + if (options.onReady) + await options.onReady({ origin, port, app }) + + return { + origin, + port, + app, + wss, + rpcGroup, + async close() { + // `wss.close` only stops accepting new connections — existing ones + // would keep the close callback pending until they disconnect on + // their own. Force-terminate so callers can deterministically tear + // the server down (tests, hot reload, graceful shutdown). + for (const ws of wss.clients) ws.terminate() + await new Promise(r => wss.close(() => r())) + await new Promise(r => httpServer.close(() => r())) + }, + } +} diff --git a/packages/devframe/src/node/static-dump.ts b/packages/devframe/src/node/static-dump.ts new file mode 100644 index 0000000..27839a2 --- /dev/null +++ b/packages/devframe/src/node/static-dump.ts @@ -0,0 +1,140 @@ +import type { RpcDumpRecord, RpcFunctionDefinitionAny } from 'devframe/rpc' +import { + DEVTOOLS_RPC_DUMP_DIRNAME, +} from 'devframe/constants' +import { dumpFunctions, getRpcHandler } from 'devframe/rpc' + +export type StaticRpcDumpSerialization = 'json' | 'structured-clone' + +export interface StaticRpcDumpManifestStaticEntry { + type: 'static' + path: string + /** Encoder used when this entry's file was written. Default: `'json'`. */ + serialization?: StaticRpcDumpSerialization +} + +export interface StaticRpcDumpManifestQueryEntry { + type: 'query' + records: Record + fallback?: string + /** Encoder used when each record/fallback file was written. Default: `'json'`. */ + serialization?: StaticRpcDumpSerialization +} + +export type StaticRpcDumpManifestValue + = | StaticRpcDumpManifestStaticEntry + | StaticRpcDumpManifestQueryEntry + | any + +export type StaticRpcDumpManifest = Record + +export interface StaticRpcDumpFile { + /** Whether this file was written via `JSON.stringify` or `structured-clone-es.stringify`. */ + serialization: StaticRpcDumpSerialization + /** Function name the file belongs to — used to scope `DF0019` errors during write. */ + fnName: string + /** Payload to encode. */ + data: unknown +} + +export interface StaticRpcDumpCollection { + manifest: StaticRpcDumpManifest + files: Record +} + +function makeDumpKey(name: string): string { + return encodeURIComponent(name.replaceAll(':', '~')) +} + +function makeStaticPath(name: string): string { + return `${DEVTOOLS_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.static.json` +} + +function makeQueryRecordPath(name: string, hash: string): string { + return `${DEVTOOLS_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.record.${hash}.json` +} + +function makeQueryFallbackPath(name: string): string { + return `${DEVTOOLS_RPC_DUMP_DIRNAME}/${makeDumpKey(name)}.fallback.json` +} + +async function resolveRecord(record: RpcDumpRecord | (() => Promise)): Promise { + return typeof record === 'function' + ? await record() + : record +} + +export async function collectStaticRpcDump( + definitions: Iterable, + context: any, +): Promise { + const manifest: StaticRpcDumpManifest = {} + const files: Record = {} + + for (const definition of definitions) { + const type = definition.type ?? 'query' + const serialization: StaticRpcDumpSerialization + = definition.jsonSerializable === true ? 'json' : 'structured-clone' + + if (type === 'static') { + const handler = await getRpcHandler(definition, context) + const path = makeStaticPath(definition.name) + files[path] = { + serialization, + fnName: definition.name, + data: { output: await Promise.resolve(handler()) }, + } + manifest[definition.name] = { + type: 'static', + path, + serialization, + } + continue + } + + if (type !== 'query') + continue + + // Reuse dump execution semantics from devframe/rpc. + const store = await dumpFunctions([definition], context) + if (!(definition.name in store.definitions)) + continue + + const queryEntry: StaticRpcDumpManifestQueryEntry = { + type: 'query', + records: {}, + serialization, + } + + const prefix = `${definition.name}---` + + for (const [recordKey, recordOrGetter] of Object.entries(store.records)) { + if (!recordKey.startsWith(prefix)) + continue + + const key = recordKey.slice(prefix.length) + const record = await resolveRecord(recordOrGetter) + + if (key === 'fallback') { + const path = makeQueryFallbackPath(definition.name) + files[path] = { serialization, fnName: definition.name, data: record } + queryEntry.fallback = path + } + else { + const path = makeQueryRecordPath(definition.name, key) + files[path] = { serialization, fnName: definition.name, data: record } + queryEntry.records[key] = path + } + } + + if (!Object.keys(queryEntry.records).length && !queryEntry.fallback) + continue + + manifest[definition.name] = queryEntry + } + + return { + manifest, + files, + } +} diff --git a/packages/devframe/src/node/storage.ts b/packages/devframe/src/node/storage.ts new file mode 100644 index 0000000..4f4c028 --- /dev/null +++ b/packages/devframe/src/node/storage.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import { createSharedState } from 'devframe/utils/shared-state' +import { dirname } from 'pathe' +import { debounce } from 'perfect-debounce' +import { logger } from './diagnostics' + +export interface CreateStorageOptions { + filepath: string + initialValue: T + mergeInitialValue?: false | ((initialValue: T, savedValue: T) => T) + debounce?: number +} + +export function createStorage(options: CreateStorageOptions) { + const { + mergeInitialValue = (initialValue, savedValue) => ({ ...initialValue, ...savedValue }), + debounce: debounceTime = 100, + } = options + + let initialValue: T = options.initialValue + if (fs.existsSync(options.filepath)) { + try { + const savedValue = JSON.parse(fs.readFileSync(options.filepath, 'utf-8')) as T + initialValue = mergeInitialValue ? mergeInitialValue(options.initialValue, savedValue) : savedValue + } + catch (error) { + logger.DF0012({ filepath: options.filepath }, { cause: error }).log() + initialValue = options.initialValue + } + } + + const state = createSharedState({ + initialValue, + enablePatches: false, + }) + + // throttle the write to the file + state.on( + 'updated', + debounce((newState) => { + fs.mkdirSync(dirname(options.filepath), { recursive: true }) + fs.writeFileSync(options.filepath, `${JSON.stringify(newState, null, 2)}\n`) + }, debounceTime), + ) + + return state +} diff --git a/packages/devframe/src/node/utils.ts b/packages/devframe/src/node/utils.ts new file mode 100644 index 0000000..c82fd66 --- /dev/null +++ b/packages/devframe/src/node/utils.ts @@ -0,0 +1,16 @@ +import { isIP } from 'node:net' + +export function isObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]' +} + +export function normalizeHttpServerUrl(host: string, port: number | string): string { + const normalizedHost + = host === '127.0.0.1' + ? 'localhost' + : isIP(host) === 6 + ? `[${host}]` + : host + + return `http://${normalizedHost}:${port}` +} diff --git a/packages/devframe/src/recipes/__tests__/open-helpers.test.ts b/packages/devframe/src/recipes/__tests__/open-helpers.test.ts new file mode 100644 index 0000000..5526e0b --- /dev/null +++ b/packages/devframe/src/recipes/__tests__/open-helpers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { openHelpers, openInEditor, openInFinder } from '../open-helpers' + +describe('recipes/open-helpers', () => { + it('exposes `openInEditor` as a devframe-namespaced action', () => { + expect(openInEditor.name).toBe('devframe:open-in-editor') + expect(openInEditor.type).toBe('action') + expect(openInEditor.args).toHaveLength(1) + expect(typeof openInEditor.handler).toBe('function') + }) + + it('exposes `openInFinder` as a devframe-namespaced action', () => { + expect(openInFinder.name).toBe('devframe:open-in-finder') + expect(openInFinder.type).toBe('action') + expect(openInFinder.args).toHaveLength(1) + expect(typeof openInFinder.handler).toBe('function') + }) + + it('bundles both helpers in `openHelpers`', () => { + expect(openHelpers).toHaveLength(2) + expect(openHelpers).toContain(openInEditor) + expect(openHelpers).toContain(openInFinder) + }) +}) diff --git a/packages/devframe/src/recipes/open-helpers.ts b/packages/devframe/src/recipes/open-helpers.ts new file mode 100644 index 0000000..e2edd94 --- /dev/null +++ b/packages/devframe/src/recipes/open-helpers.ts @@ -0,0 +1,66 @@ +import * as v from 'valibot' +import { defineRpcFunction } from '../rpc/define' +import { launchEditor } from '../utils/launch-editor' +import { open } from '../utils/open' + +/** + * Prebuilt RPC action that opens a file in the user's configured editor. + * + * Registered name: `devframe:open-in-editor`. + * + * ```ts + * import { openInEditor } from 'devframe/recipes/open-helpers' + * + * defineDevframe({ + * id: 'my-tool', + * name: 'My Tool', + * setup(ctx) { + * ctx.rpc.register(openInEditor) + * }, + * }) + * ``` + */ +export const openInEditor = defineRpcFunction({ + name: 'devframe:open-in-editor', + type: 'action', + jsonSerializable: true, + args: [v.string()], + returns: v.void(), + async handler(filename: string) { + launchEditor(filename) + }, +}) + +/** + * Prebuilt RPC action that reveals a path in the OS file explorer. + * + * Registered name: `devframe:open-in-finder`. + * + * ```ts + * import { openInFinder } from 'devframe/recipes/open-helpers' + * + * ctx.rpc.register(openInFinder) + * ``` + */ +export const openInFinder = defineRpcFunction({ + name: 'devframe:open-in-finder', + type: 'action', + jsonSerializable: true, + args: [v.string()], + returns: v.void(), + async handler(path: string) { + await open(path) + }, +}) + +/** + * Convenience array bundling both helpers so callers can register them + * in a single `forEach`. + * + * ```ts + * import { openHelpers } from 'devframe/recipes/open-helpers' + * + * openHelpers.forEach(fn => ctx.rpc.register(fn)) + * ``` + */ +export const openHelpers = [openInEditor, openInFinder] as const diff --git a/packages/devframe/src/rpc/__snapshots__/dumps.test.ts.snap b/packages/devframe/src/rpc/__snapshots__/dumps.test.ts.snap new file mode 100644 index 0000000..d02630f --- /dev/null +++ b/packages/devframe/src/rpc/__snapshots__/dumps.test.ts.snap @@ -0,0 +1,316 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`dumps > dump snapshots > should snapshot comprehensive dump with multiple scenarios 1`] = ` +{ + "definitions": { + "add": { + "name": "add", + "type": undefined, + }, + "divide": { + "name": "divide", + "type": undefined, + }, + "getConfig": { + "name": "getConfig", + "type": "static", + }, + "multiply": { + "name": "multiply", + "type": undefined, + }, + }, + "records": { + "add---KMXmOL3ys81zNdaAp_MtASEYJWGbVMdGiX1IQsRrWHg": { + "inputs": [ + 10, + 20, + ], + "output": 30, + }, + "add---SaZHF9XUyxmVLm6sKUZBXPaHmtrPmQjn2HIzLTLG5oQ": { + "inputs": [ + 1, + 2, + ], + "output": 3, + }, + "add---fallback": { + "inputs": [], + "output": 0, + }, + "divide---Ql6R6f8bSyzgiAJFMv-GGfAebD0Q1EvpkCvtiEN8Ojk": { + "inputs": [ + 10, + 2, + ], + "output": 5, + }, + "divide---clP6nyWEfmD9PThtSkrVBIT7mo2UYRyJ8lCA18HUJVI": { + "error": { + "message": "Division by zero", + "name": "Error", + }, + "inputs": [ + 10, + 0, + ], + }, + "getConfig---T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU": { + "inputs": [], + "output": { + "version": "1.0.0", + }, + }, + "multiply---GLBYaJ0BAGKiXNf7lJsqv-oXV9LeRco6vdoX25VuUGs": { + "inputs": [ + 2, + 3, + ], + "output": 6, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with context-dependent functions 1`] = ` +{ + "definitions": { + "getConfig": { + "name": "getConfig", + "type": undefined, + }, + }, + "records": { + "getConfig---Xl4-LA88xSJCWOOL_2hL9CFG3twBWmstDJ_Yqg77nWo": { + "inputs": [ + "apiUrl", + ], + "output": { + "apiUrl": "http://localhost:3000", + "debug": true, + }, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with errors 1`] = ` +{ + "definitions": { + "divide": { + "name": "divide", + "type": undefined, + }, + }, + "records": { + "divide---NKq_csykACfT_qAmmFEcDyyuAxmaR7agpibi0BY6-oY": { + "inputs": [ + 20, + 4, + ], + "output": 5, + }, + "divide---Ql6R6f8bSyzgiAJFMv-GGfAebD0Q1EvpkCvtiEN8Ojk": { + "inputs": [ + 10, + 2, + ], + "output": 5, + }, + "divide---clP6nyWEfmD9PThtSkrVBIT7mo2UYRyJ8lCA18HUJVI": { + "error": { + "message": "Division by zero", + "name": "Error", + }, + "inputs": [ + 10, + 0, + ], + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with fallback values 1`] = ` +{ + "definitions": { + "greet": { + "name": "greet", + "type": undefined, + }, + }, + "records": { + "greet---_O8fg6KUjOBNeL20buGWXHbcFzcF4DNQsu_J9Yd8esk": { + "inputs": [ + "Bob", + ], + "output": "Hello, Bob!", + }, + "greet---acIfQW5j2P6VtOYcCUwCi1SNi7Phz7_1JeqXfb4eb3s": { + "inputs": [ + "Alice", + ], + "output": "Hello, Alice!", + }, + "greet---fallback": { + "inputs": [], + "output": "Hello, stranger!", + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with mixed inputs and records 1`] = ` +{ + "definitions": { + "add": { + "name": "add", + "type": undefined, + }, + }, + "records": { + "add---KMXmOL3ys81zNdaAp_MtASEYJWGbVMdGiX1IQsRrWHg": { + "inputs": [ + 10, + 20, + ], + "output": 30, + }, + "add---SaZHF9XUyxmVLm6sKUZBXPaHmtrPmQjn2HIzLTLG5oQ": { + "inputs": [ + 1, + 2, + ], + "output": 3, + }, + "add---i-bWbpCZxo2P61LOQkeNIVPKwnY7eEF0rmrpbNY2tZY": { + "inputs": [ + 3, + 4, + ], + "output": 7, + }, + "add---mERJaNNmqgwfjCQYdik6YYdy6IwTcTZ1rmtFR7DzNRw": { + "inputs": [ + 100, + 200, + ], + "output": 300, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with pre-computed records 1`] = ` +{ + "definitions": { + "multiply": { + "name": "multiply", + "type": undefined, + }, + }, + "records": { + "multiply---1MepjaVUkLClplzFBX25mqcIpDZgmxd3SFBTQtVpRXs": { + "inputs": [ + 4, + 5, + ], + "output": 20, + }, + "multiply---GLBYaJ0BAGKiXNf7lJsqv-oXV9LeRco6vdoX25VuUGs": { + "inputs": [ + 2, + 3, + ], + "output": 6, + }, + "multiply---clP6nyWEfmD9PThtSkrVBIT7mo2UYRyJ8lCA18HUJVI": { + "inputs": [ + 10, + 0, + ], + "output": 0, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with static functions 1`] = ` +{ + "definitions": { + "getConfig": { + "name": "getConfig", + "type": "static", + }, + "getVersion": { + "name": "getVersion", + "type": "static", + }, + }, + "records": { + "getConfig---T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU": { + "inputs": [], + "output": { + "apiUrl": "https://api.example.com", + "features": [ + "auth", + "cache", + ], + "version": "v1", + }, + }, + "getVersion---T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU": { + "inputs": [], + "output": "1.0.0", + }, + }, +} +`; + +exports[`dumps > should snapshot the store structure 1`] = ` +{ + "definitions": { + "add": { + "name": "add", + "type": undefined, + }, + "greet": { + "name": "greet", + "type": undefined, + }, + }, + "records": { + "add---SaZHF9XUyxmVLm6sKUZBXPaHmtrPmQjn2HIzLTLG5oQ": { + "inputs": [ + 1, + 2, + ], + "output": 3, + }, + "add---fallback": { + "inputs": [], + "output": 0, + }, + "add---i-bWbpCZxo2P61LOQkeNIVPKwnY7eEF0rmrpbNY2tZY": { + "inputs": [ + 3, + 4, + ], + "output": 7, + }, + "greet---_O8fg6KUjOBNeL20buGWXHbcFzcF4DNQsu_J9Yd8esk": { + "inputs": [ + "Bob", + ], + "output": "Hello, Bob!", + }, + "greet---acIfQW5j2P6VtOYcCUwCi1SNi7Phz7_1JeqXfb4eb3s": { + "inputs": [ + "Alice", + ], + "output": "Hello, Alice!", + }, + }, +} +`; diff --git a/packages/devframe/src/rpc/cache.test.ts b/packages/devframe/src/rpc/cache.test.ts new file mode 100644 index 0000000..bdc1dcb --- /dev/null +++ b/packages/devframe/src/rpc/cache.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest' +import { RpcCacheManager } from './cache' + +it('cache', async () => { + const cache = new RpcCacheManager({ functions: ['fn3'] }) + + expect(cache.validate('fn1')).toBe(false) + expect(cache.validate('fn3')).toBe(true) + + cache.updateOptions({ functions: ['fn1', 'fn2'] }) + expect(cache.validate('fn1')).toBe(true) + expect(cache.validate('fn2')).toBe(true) + expect(cache.validate('fn3')).toBe(false) + cache.apply({ m: 'fn1', a: [1, 2] }, 100) + cache.apply({ m: 'fn2', a: [3, 4] }, 200) + expect(cache.cached('fn1', [1, 2])).toBe(100) + cache.clear('fn1') + expect(cache.cached('fn1', [1, 2])).toBeUndefined() + cache.clear() + expect(cache.cached('fn2', [3, 4])).toBeUndefined() +}) diff --git a/packages/devframe/src/rpc/cache.ts b/packages/devframe/src/rpc/cache.ts new file mode 100644 index 0000000..704ec28 --- /dev/null +++ b/packages/devframe/src/rpc/cache.ts @@ -0,0 +1,54 @@ +import { hash } from '../utils/hash' + +export interface RpcCacheOptions { + functions: string[] + keySerializer?: (args: unknown[]) => string +} + +/** + * @experimental API is expected to change. + */ +export class RpcCacheManager { + private cacheMap = new Map>() + private options: RpcCacheOptions + private keySerializer: (args: unknown[]) => string + + constructor(options: RpcCacheOptions) { + this.options = options + this.keySerializer = options.keySerializer || ((args: unknown[]) => hash(args)) + } + + updateOptions(options: Partial): void { + this.options = { + ...this.options, + ...options, + } + } + + cached(m: string, a: unknown[]): T | undefined { + const methodCache = this.cacheMap.get(m) + if (methodCache) { + return methodCache.get(this.keySerializer(a)) as T + } + return undefined + } + + apply(req: { m: string, a: unknown[] }, res: unknown): void { + const methodCache = this.cacheMap.get(req.m) || new Map() + methodCache.set(this.keySerializer(req.a), res) + this.cacheMap.set(req.m, methodCache) + } + + validate(m: string): boolean { + return this.options.functions.includes(m) + } + + clear(fn?: string): void { + if (fn) { + this.cacheMap.delete(fn) + } + else { + this.cacheMap.clear() + } + } +} diff --git a/packages/devframe/src/rpc/client.ts b/packages/devframe/src/rpc/client.ts new file mode 100644 index 0000000..0c32d49 --- /dev/null +++ b/packages/devframe/src/rpc/client.ts @@ -0,0 +1,21 @@ +import type { BirpcOptions, BirpcReturn, ChannelOptions } from 'birpc' +import { createBirpc } from 'birpc' + +export function createRpcClient< + ServerFunctions extends object = Record, + ClientFunctions extends object = Record, +>( + functions: ClientFunctions, + options: { + channel: ChannelOptions + rpcOptions?: Partial> + }, +): BirpcReturn { + const { channel, rpcOptions = {} } = options + return createBirpc(functions, { + ...channel, + timeout: -1, + ...rpcOptions, + proxify: false, + }) +} diff --git a/packages/devframe/src/rpc/collector.test.ts b/packages/devframe/src/rpc/collector.test.ts new file mode 100644 index 0000000..b5c833f --- /dev/null +++ b/packages/devframe/src/rpc/collector.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcFunctionsCollectorBase } from './collector' + +describe('agent gating (DF0019)', () => { + it('rejects registration when agent is set without jsonSerializable: true', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + agent: { description: 'x' }, + handler: () => 0, + } as any)).toThrowError(/MCP requires JSON-serializable/) + }) + + it('rejects when agent + jsonSerializable: false', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + agent: { description: 'x' }, + jsonSerializable: false, + handler: () => 0, + } as any)).toThrowError(/MCP requires JSON-serializable/) + }) + + it('accepts agent + jsonSerializable: true', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + agent: { description: 'x' }, + jsonSerializable: true, + handler: () => 0, + } as any)).not.toThrow() + }) + + it('accepts jsonSerializable: false without agent (RPC-only)', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + jsonSerializable: false, + handler: () => 0, + } as any)).not.toThrow() + }) + + it('also enforces the gate on update()', () => { + const collector = new RpcFunctionsCollectorBase({}) + collector.register({ name: 'plugin:fn', handler: () => 0 } as any) + expect(() => collector.update({ + name: 'plugin:fn', + agent: { description: 'x' }, + handler: () => 0, + } as any)).toThrowError(/MCP requires JSON-serializable/) + }) +}) + +it('collector', async () => { + const context = { + name: 'test', + } + const collector = new RpcFunctionsCollectorBase(context) + + expect(collector.functions).toMatchInlineSnapshot(`{}`) + + let _context: any + collector.register({ + name: 'hello', + type: 'static', + setup: async (_c) => { + await new Promise(resolve => setTimeout(resolve, 1)) + _context = _c + return { + handler: () => 100, + } + }, + }) + + expect((await collector.getHandler('hello'))()).toBe(100) + expect(_context).toBe(context) + + const onUpdate = vi.fn() + const handler = vi.fn() + collector.onChanged(onUpdate) + collector.register({ + name: 'new', + type: 'action', + handler, + }) + expect(onUpdate).toHaveBeenCalledWith('new') + + expect((await collector.getHandler('new'))()).toBe(undefined) + expect(handler).toBeCalled() + + onUpdate.mockClear() + handler.mockClear() + collector.update({ + name: 'new', + type: 'static', + handler: () => 100, + }) + expect(onUpdate).toHaveBeenCalledWith('new') + expect((await collector.getHandler('new'))()).toBe(100) + expect(handler).not.toBeCalled() +}) diff --git a/packages/devframe/src/rpc/collector.ts b/packages/devframe/src/rpc/collector.ts new file mode 100644 index 0000000..6fdcd9f --- /dev/null +++ b/packages/devframe/src/rpc/collector.ts @@ -0,0 +1,102 @@ +import type { RpcArgsSchema, RpcFunctionDefinition, RpcFunctionsCollector, RpcReturnSchema } from './types' +import { logger } from './diagnostics' +import { getRpcHandler } from './handler' + +export class RpcFunctionsCollectorBase< + LocalFunctions extends Record, + SetupContext, +> implements RpcFunctionsCollector { + public readonly definitions: Map> = new Map() + public readonly functions: LocalFunctions + private readonly _onChanged: ((id?: string) => void)[] = [] + + constructor( + public readonly context: SetupContext, + ) { + const definitions = this.definitions + // eslint-disable-next-line ts/no-this-alias + const self = this + this.functions = new Proxy({}, { + get(_, prop) { + const definition = definitions.get(prop as string) + if (!definition) + return undefined + return getRpcHandler(definition, self.context) + }, + has(_, prop) { + return definitions.has(prop as string) + }, + getOwnPropertyDescriptor(_, prop) { + return { + value: definitions.get(prop as string)?.handler, + configurable: true, + enumerable: true, + } + }, + ownKeys() { + return Array.from(definitions.keys()) + }, + }) as LocalFunctions + } + + register(fn: RpcFunctionDefinition, force = false): void { + if (this.definitions.has(fn.name) && !force) { + throw logger.DF0021({ name: fn.name }).throw() + } + assertAgentJsonSerializable(fn) + this.definitions.set(fn.name, fn) + this._onChanged.forEach(cb => cb(fn.name)) + } + + update(fn: RpcFunctionDefinition, force = false): void { + if (!this.definitions.has(fn.name) && !force) { + throw logger.DF0022({ name: fn.name }).throw() + } + assertAgentJsonSerializable(fn) + this.definitions.set(fn.name, fn) + this._onChanged.forEach(cb => cb(fn.name)) + } + + onChanged(fn: (id?: string) => void): () => void { + this._onChanged.push(fn) + return () => { + const index = this._onChanged.indexOf(fn) + if (index !== -1) { + this._onChanged.splice(index, 1) + } + } + } + + async getHandler(name: T): Promise { + return await getRpcHandler(this.definitions.get(name as string)!, this.context) as LocalFunctions[T] + } + + getSchema(name: T): { args: RpcArgsSchema | undefined, returns: RpcReturnSchema | undefined } { + const definition = this.definitions.get(name as string) + if (!definition) + throw logger.DF0023({ name: String(name) }).throw() + return { + args: definition.args, + returns: definition.returns, + } + } + + has(name: string): boolean { + return this.definitions.has(name) + } + + get(name: string): RpcFunctionDefinition | undefined { + return this.definitions.get(name) + } + + list(): string[] { + return Array.from(this.definitions.keys()) + } +} + +function assertAgentJsonSerializable( + fn: RpcFunctionDefinition, +): void { + if (fn.agent && fn.jsonSerializable !== true) + throw logger.DF0019({ name: fn.name }).throw() +} diff --git a/packages/devframe/src/rpc/define.ts b/packages/devframe/src/rpc/define.ts new file mode 100644 index 0000000..43aae86 --- /dev/null +++ b/packages/devframe/src/rpc/define.ts @@ -0,0 +1,29 @@ +import type { RpcArgsSchema, RpcFunctionDefinition, RpcFunctionType, RpcReturnSchema } from './types' + +export function defineRpcFunction< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + const AS extends RpcArgsSchema | undefined = undefined, + const RS extends RpcReturnSchema | undefined = undefined, +>( + definition: RpcFunctionDefinition, +): RpcFunctionDefinition { + return definition +} + +export function createDefineWrapperWithContext() { + return function defineRpcFunctionWithContext< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + const AS extends RpcArgsSchema | undefined = undefined, + const RS extends RpcReturnSchema | undefined = undefined, + >( + definition: RpcFunctionDefinition, + ): RpcFunctionDefinition { + return definition + } +} diff --git a/packages/devframe/src/rpc/diagnostics.ts b/packages/devframe/src/rpc/diagnostics.ts new file mode 100644 index 0000000..05d023c --- /dev/null +++ b/packages/devframe/src/rpc/diagnostics.ts @@ -0,0 +1,49 @@ +import { consoleReporter, createLogger, defineDiagnostics, plainFormatter } from 'logs-sdk' + +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DF0019: { + message: (p: { name: string }) => + `RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\` — MCP requires JSON-serializable data.`, + hint: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.', + }, + DF0020: { + message: (p: { name: string, type: string, path: string }) => + `RPC function "${p.name}" declares \`jsonSerializable: true\` but the value at "${p.path}" is a ${p.type}.`, + hint: 'Either drop `jsonSerializable: true` (falls back to structured-clone) or change the value to a JSON-safe shape.', + }, + DF0021: { + message: (p: { name: string }) => `RPC function "${p.name}" is already registered`, + hint: 'Use the `force` parameter to overwrite an existing registration.', + }, + DF0022: { + message: (p: { name: string }) => `RPC function "${p.name}" is not registered. Use register() to add new functions.`, + }, + DF0023: { + message: (p: { name: string }) => `RPC function "${p.name}" is not registered`, + }, + DF0024: { + message: (p: { name: string }) => `Either handler or setup function must be provided for RPC function "${p.name}"`, + }, + DF0025: { + message: (p: { name: string }) => `Function "${p.name}" not found in dump store`, + }, + DF0026: { + message: (p: { name: string, args: string }) => `No dump match for "${p.name}" with args: ${p.args}`, + }, + DF0027: { + message: (p: { name: string, type: string }) => `Function "${p.name}" with type "${p.type}" cannot have dump configuration. Only "static" and "query" types support dumps.`, + }, + DF0028: { + message: (p: { name: string, type: string }) => `Function "${p.name}" with type "${p.type}" cannot use \`snapshot: true\`. Only "query" functions support this sugar; "static" functions have equivalent default behavior already.`, + hint: 'Remove `snapshot: true`, or change the function type to `query`.', + }, + }, +}) + +export const logger = createLogger({ + diagnostics: [diagnostics], + formatter: plainFormatter, + reporters: consoleReporter, +}) diff --git a/packages/devframe/src/rpc/dumps.test.ts b/packages/devframe/src/rpc/dumps.test.ts new file mode 100644 index 0000000..1284332 --- /dev/null +++ b/packages/devframe/src/rpc/dumps.test.ts @@ -0,0 +1,922 @@ +import type { RpcDumpRecord } from './types' +import * as v from 'valibot' +import { describe, expect, it } from 'vitest' +import { createClientFromDump, createDefineWrapperWithContext, defineRpcFunction, dumpFunctions } from '.' + +describe('dumps', () => { + it('should collect dumps from definition', async () => { + const add = defineRpcFunction({ + name: 'add', + dump: { + inputs: [[1, 2], [3, 4], [5, 6]], + }, + handler: (a: number, b: number) => a + b, + }) + + const store = await dumpFunctions([add]) + + expect(Object.keys(store.definitions).length).toBe(1) + expect('add' in store.definitions).toBe(true) + + // Get all records for 'add' function + const addRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('add---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(addRecords.length).toBe(3) + expect(addRecords[0]).toMatchObject({ inputs: [1, 2], output: 3 }) + expect(addRecords[1]).toMatchObject({ inputs: [3, 4], output: 7 }) + expect(addRecords[2]).toMatchObject({ inputs: [5, 6], output: 11 }) + }) + + it('should create dump client and return correct results', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob'], ['Charlie']], + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + const client = createClientFromDump(store) + + await expect(client.greet('Alice')).resolves.toBe('Hello, Alice!') + await expect(client.greet('Bob')).resolves.toBe('Hello, Bob!') + await expect(client.greet('Charlie')).resolves.toBe('Hello, Charlie!') + }) + + it('should return fallback for non-matching args when fallback is provided', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + fallback: 'Hello, stranger!', + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + const client = createClientFromDump(store) + + await expect(client.greet('Alice')).resolves.toBe('Hello, Alice!') + await expect(client.greet('Unknown')).resolves.toBe('Hello, stranger!') + }) + + it('should throw error for non-matching args when fallback is not provided', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + const client = createClientFromDump(store) + + await expect(client.greet('Alice')).resolves.toBe('Hello, Alice!') + await expect(client.greet('Unknown')).rejects.toThrow('No dump match for "greet"') + }) + + it('should handle errors in dumps', async () => { + const divide = defineRpcFunction({ + name: 'divide', + dump: { + inputs: [[10, 2], [10, 0]], + }, + handler: (a: number, b: number) => { + if (b === 0) + throw new Error('Division by zero') + return a / b + }, + }) + + const store = await dumpFunctions([divide]) + + // Get all records for 'divide' function + const divideRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('divide---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(divideRecords[0]).toMatchObject({ inputs: [10, 2], output: 5 }) + expect(divideRecords[1]!.inputs).toEqual([10, 0]) + expect(divideRecords[1]!.error).toBeDefined() + expect(divideRecords[1]!.error?.message).toBe('Division by zero') + expect(divideRecords[1]!.error?.name).toBe('Error') + + const client = createClientFromDump(store) + + await expect(client.divide(10, 2)).resolves.toBe(5) + await expect(client.divide(10, 0)).rejects.toThrow('Division by zero') + }) + + it('should collect dumps from setup result', async () => { + const defineWithContext = createDefineWrapperWithContext<{ balance: number }>() + + const getBalance = defineWithContext({ + name: 'getBalance', + setup: (context) => { + return { + handler: () => context.balance, + dump: { + inputs: [[]] as [][], // Type as tuple array + }, + } + }, + }) + + const store = await dumpFunctions([getBalance], { balance: 100 }) + + // Get all records for 'getBalance' function + const balanceRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getBalance---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(balanceRecords.length).toBe(1) + expect(balanceRecords[0]).toMatchObject({ inputs: [], output: 100 }) + + const client = createClientFromDump(store) + await expect(client.getBalance()).resolves.toBe(100) + }) + + it('should prioritize setup dumps over definition dumps', async () => { + const defineWithContext = createDefineWrapperWithContext<{ multiplier: number }>() + + const getValue = defineWithContext({ + name: 'getValue', + dump: { + inputs: [[1], [2]], + }, + setup: (context) => { + return { + handler: (x: number) => x * context.multiplier, + dump: { + inputs: [[5], [10]], // Different inputs from definition + }, + } + }, + }) + + const store = await dumpFunctions([getValue], { multiplier: 3 }) + + // Get all records for 'getValue' function + const valueRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getValue---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + // Should use setup dumps, not definition dumps + expect(valueRecords.length).toBe(2) + expect(valueRecords[0]).toMatchObject({ inputs: [5], output: 15 }) + expect(valueRecords[1]).toMatchObject({ inputs: [10], output: 30 }) + }) + + it('should handle context-dependent dumps', async () => { + const defineWithContext = createDefineWrapperWithContext<{ env: 'dev' | 'prod' }>() + + const getConfig = defineWithContext({ + name: 'getConfig', + setup: (context) => { + return { + handler: (_key: string) => { + const configs = { + dev: { apiUrl: 'http://localhost:3000' }, + prod: { apiUrl: 'https://api.example.com' }, + } + return configs[context.env] + }, + dump: { + inputs: [['apiUrl']] as [string][], // Type as tuple array + }, + } + }, + }) + + const devStore = await dumpFunctions([getConfig], { env: 'dev' }) + const devClient = createClientFromDump(devStore) + await expect(devClient.getConfig('apiUrl')).resolves.toEqual({ apiUrl: 'http://localhost:3000' }) + + const prodStore = await dumpFunctions([getConfig], { env: 'prod' }) + const prodClient = createClientFromDump(prodStore) + await expect(prodClient.getConfig('apiUrl')).resolves.toEqual({ apiUrl: 'https://api.example.com' }) + }) + + it('should match arguments correctly with ohash', async () => { + const complexArgs = defineRpcFunction({ + name: 'complexArgs', + dump: { + inputs: [ + [{ id: 1, name: 'Alice' }, [1, 2, 3]], + [{ id: 2, name: 'Bob' }, [4, 5, 6]], + ], + }, + handler: (user: { id: number, name: string }, nums: number[]) => { + return `${user.name}: ${nums.join(',')}` + }, + }) + + const store = await dumpFunctions([complexArgs]) + const client = createClientFromDump(store) + + await expect(client.complexArgs({ id: 1, name: 'Alice' }, [1, 2, 3])).resolves.toBe('Alice: 1,2,3') + await expect(client.complexArgs({ id: 2, name: 'Bob' }, [4, 5, 6])).resolves.toBe('Bob: 4,5,6') + + // Different order should still match due to object hashing + await expect(client.complexArgs({ name: 'Alice', id: 1 }, [1, 2, 3])).resolves.toBe('Alice: 1,2,3') + }) + + it('should call onMiss callback when no match found', async () => { + const add = defineRpcFunction({ + name: 'add', + dump: { + inputs: [[1, 2]], + fallback: 0, + }, + handler: (a: number, b: number) => a + b, + }) + + const store = await dumpFunctions([add]) + + const misses: Array<{ functionName: string, args: any[] }> = [] + const client = createClientFromDump(store, { + onMiss: (functionName, args) => { + misses.push({ functionName, args }) + }, + }) + + await expect(client.add(1, 2)).resolves.toBe(3) + expect(misses).toHaveLength(0) + + await expect(client.add(3, 4)).resolves.toBe(0) // Returns fallback + expect(misses).toHaveLength(1) + expect(misses[0]).toEqual({ functionName: 'add', args: [3, 4] }) + }) + + it('should throw error for non-existent function', async () => { + const add = defineRpcFunction({ + name: 'add', + dump: { + inputs: [[1, 2]], + }, + handler: (a: number, b: number) => a + b, + }) + + const store = await dumpFunctions([add]) + const client = createClientFromDump(store) + + expect(() => (client as any).subtract(1, 2)).toThrow('Function "subtract" not found in dump store') + }) + + it('should skip functions without dumps during collection', async () => { + const withDump = defineRpcFunction({ + name: 'withDump', + dump: { + inputs: [[1]], + }, + handler: (x: number) => x * 2, + }) + + const withoutDump = defineRpcFunction({ + name: 'withoutDump', + handler: (x: number) => x * 3, + }) + + const store = await dumpFunctions([withDump, withoutDump]) + + expect(Object.keys(store.definitions).length).toBe(1) + expect('withDump' in store.definitions).toBe(true) + expect('withoutDump' in store.definitions).toBe(false) + }) + + it('should handle async handlers', async () => { + const fetchData = defineRpcFunction({ + name: 'fetchData', + dump: { + inputs: [['user1'], ['user2']], + }, + handler: async (id: string) => { + await new Promise(resolve => setTimeout(resolve, 10)) + return { id, data: `Data for ${id}` } + }, + }) + + const store = await dumpFunctions([fetchData]) + + // Get all records for 'fetchData' function + const fetchDataRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('fetchData---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(fetchDataRecords.length).toBe(2) + expect(fetchDataRecords[0]?.output).toEqual({ id: 'user1', data: 'Data for user1' }) + expect(fetchDataRecords[1]?.output).toEqual({ id: 'user2', data: 'Data for user2' }) + }) + + it('should preserve metadata in dumps', async () => { + const getUser = defineRpcFunction({ + name: 'getUser', + type: 'query', + args: [v.string()], + returns: v.object({ id: v.string(), name: v.string() }), + dump: { + inputs: [['user1']], + }, + handler: (id: string) => ({ id, name: `User ${id}` }), + }) + + const store = await dumpFunctions([getUser]) + const userDefinition = store.definitions.getUser + + expect(userDefinition!.name).toBe('getUser') + expect(userDefinition!.type).toBe('query') + }) + + it('should support dump as a getter function', async () => { + const defineWithContext = createDefineWrapperWithContext<{ multiplier: number, values: number[] }>() + + const multiply = defineWithContext({ + name: 'multiply', + handler: (x: number) => x * 10, + dump: (context, _handler) => ({ + inputs: context.values.map(v => [v * context.multiplier]), + fallback: 0, + }), + }) + + const store = await dumpFunctions([multiply], { multiplier: 2, values: [1, 2, 3] }) + + // Get all records for 'multiply' function (excluding fallback) + const multiplyRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('multiply---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(multiplyRecords.length).toBe(3) + expect(multiplyRecords[0]).toMatchObject({ inputs: [2], output: 20 }) + expect(multiplyRecords[1]).toMatchObject({ inputs: [4], output: 40 }) + expect(multiplyRecords[2]).toMatchObject({ inputs: [6], output: 60 }) + + const client = createClientFromDump(store) + await expect(client.multiply(2)).resolves.toBe(20) + await expect(client.multiply(4)).resolves.toBe(40) + await expect(client.multiply(100)).resolves.toBe(0) // Fallback + }) + + it('should support async dump getter function', async () => { + const defineWithContext = createDefineWrapperWithContext<{ getUserIds: () => Promise }>() + + const getUser = defineWithContext({ + name: 'getUser', + handler: (id: string) => ({ id, name: `User ${id}` }), + dump: async (context, _handler) => { + const userIds = await context.getUserIds() + return { + inputs: userIds.map(id => [id]), + } + }, + }) + + const store = await dumpFunctions([getUser], { + getUserIds: async () => ['user1', 'user2'], + }) + + // Get all records for 'getUser' function + const userRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getUser---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(userRecords.length).toBe(2) + expect(userRecords[0]).toMatchObject({ inputs: ['user1'], output: { id: 'user1', name: 'User user1' } }) + expect(userRecords[1]).toMatchObject({ inputs: ['user2'], output: { id: 'user2', name: 'User user2' } }) + }) + + it('should snapshot the store structure', async () => { + const add = defineRpcFunction({ + name: 'add', + args: [v.number(), v.number()], + returns: v.number(), + dump: { + inputs: [[1, 2], [3, 4]], + fallback: 0, + }, + handler: (a: number, b: number) => a + b, + }) + + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([add, greet]) + expect(store).toMatchSnapshot() + }) + + describe('dump snapshots', () => { + it('should snapshot dump with errors', async () => { + const divide = defineRpcFunction({ + name: 'divide', + dump: { + inputs: [[10, 2], [10, 0], [20, 4]], + }, + handler: (a: number, b: number) => { + if (b === 0) + throw new Error('Division by zero') + return a / b + }, + }) + + const store = await dumpFunctions([divide]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with pre-computed records', async () => { + const multiply = defineRpcFunction({ + name: 'multiply', + handler: (a: number, b: number) => a * b, + dump: { + records: [ + { inputs: [2, 3], output: 6 }, + { inputs: [4, 5], output: 20 }, + { inputs: [10, 0], output: 0 }, + ], + }, + }) + + const store = await dumpFunctions([multiply]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with mixed inputs and records', async () => { + const add = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + dump: { + inputs: [[1, 2], [3, 4]], + records: [ + { inputs: [10, 20], output: 30 }, + { inputs: [100, 200], output: 300 }, + ], + }, + }) + + const store = await dumpFunctions([add]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with context-dependent functions', async () => { + const defineWithContext = createDefineWrapperWithContext<{ env: 'dev' | 'prod' }>() + + const getConfig = defineWithContext({ + name: 'getConfig', + setup: (context) => { + return { + handler: (_key: string) => { + const configs = { + dev: { apiUrl: 'http://localhost:3000', debug: true }, + prod: { apiUrl: 'https://api.example.com', debug: false }, + } + return configs[context.env] + }, + dump: { + inputs: [['apiUrl']] as [string][], + }, + } + }, + }) + + const store = await dumpFunctions([getConfig], { env: 'dev' }) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with fallback values', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + fallback: 'Hello, stranger!', + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with static functions', async () => { + const getVersion = defineRpcFunction({ + name: 'getVersion', + type: 'static', + handler: () => '1.0.0', + }) + + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + handler: () => ({ + apiUrl: 'https://api.example.com', + version: 'v1', + features: ['auth', 'cache'], + }), + }) + + const store = await dumpFunctions([getVersion, getConfig]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot comprehensive dump with multiple scenarios', async () => { + const divide = defineRpcFunction({ + name: 'divide', + dump: { + inputs: [[10, 2], [10, 0]], + }, + handler: (a: number, b: number) => { + if (b === 0) + throw new Error('Division by zero') + return a / b + }, + }) + + const multiply = defineRpcFunction({ + name: 'multiply', + handler: (a: number, b: number) => a * b, + dump: { + records: [ + { inputs: [2, 3], output: 6 }, + ], + }, + }) + + const add = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + dump: { + inputs: [[1, 2]], + records: [ + { inputs: [10, 20], output: 30 }, + ], + fallback: 0, + }, + }) + + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + handler: () => ({ version: '1.0.0' }), + }) + + const store = await dumpFunctions([divide, multiply, add, getConfig]) + expect(store).toMatchSnapshot() + }) + }) + + describe('snapshot sugar', () => { + it('should auto-dump a query function when `snapshot: true`', async () => { + const getPayload = defineRpcFunction({ + name: 'getPayload', + type: 'query', + snapshot: true, + handler: () => ({ packages: ['a', 'b', 'c'] }), + }) + + const store = await dumpFunctions([getPayload]) + const client = createClientFromDump(store) + + // No-args call matches the baked record. + await expect(client.getPayload()).resolves.toEqual({ packages: ['a', 'b', 'c'] }) + }) + + it('should expose the snapshot via fallback so any call variant resolves', async () => { + const getPayload = defineRpcFunction({ + name: 'getPayload', + type: 'query', + snapshot: true, + handler: (_force?: boolean) => ({ ts: 42 }), + }) + + const store = await dumpFunctions([getPayload]) + const client = createClientFromDump(store) + + // NMI calls `getPayload(force)` where force is truthy/falsy — the + // fallback ensures both variants land on the same snapshot. + await expect(client.getPayload()).resolves.toEqual({ ts: 42 }) + await expect(client.getPayload(false)).resolves.toEqual({ ts: 42 }) + await expect(client.getPayload(true)).resolves.toEqual({ ts: 42 }) + }) + + it('should default to query behavior when `snapshot: true` has no explicit type', async () => { + const getPayload = defineRpcFunction({ + name: 'getPayload', + snapshot: true, + handler: () => 'snapshot-value', + }) + + const store = await dumpFunctions([getPayload]) + const client = createClientFromDump(store) + + await expect(client.getPayload()).resolves.toBe('snapshot-value') + }) + + it('should prioritize an explicit `dump` over `snapshot: true`', async () => { + const getPayload = defineRpcFunction({ + name: 'getPayload', + type: 'query', + snapshot: true, + dump: { inputs: [['explicit']] }, + handler: (kind: string) => `from-${kind}`, + }) + + const store = await dumpFunctions([getPayload]) + const client = createClientFromDump(store) + + // Explicit dump wins; no fallback synthesized. + await expect(client.getPayload('explicit')).resolves.toBe('from-explicit') + await expect(client.getPayload('other')).rejects.toThrow('No dump match') + }) + + it('should reject `snapshot: true` on non-query functions', async () => { + const bad = defineRpcFunction({ + name: 'bad', + type: 'static', + snapshot: true, + handler: () => 'nope', + } as any) + + await expect(dumpFunctions([bad])).rejects.toThrow( + 'Function "bad" with type "static" cannot use `snapshot: true`', + ) + }) + }) + + it('should throw error if action type function has dump', async () => { + const sendEmail = defineRpcFunction({ + name: 'sendEmail', + type: 'action', + dump: { + inputs: [['test@example.com']], + }, + handler: (email: string) => `Sent to ${email}`, + }) + + await expect(dumpFunctions([sendEmail])).rejects.toThrow( + 'Function "sendEmail" with type "action" cannot have dump configuration', + ) + }) + + it('should throw error if event type function has dump', async () => { + const notifyUser = defineRpcFunction({ + name: 'notifyUser', + type: 'event', + dump: { + inputs: [['user1']], + }, + handler: (userId: string) => `Notified ${userId}`, + }) + + await expect(dumpFunctions([notifyUser])).rejects.toThrow( + 'Function "notifyUser" with type "event" cannot have dump configuration', + ) + }) + + it('should allow query type function with dump', async () => { + const getUser = defineRpcFunction({ + name: 'getUser', + type: 'query', + dump: { + inputs: [['user1']], + }, + handler: (id: string) => ({ id, name: `User ${id}` }), + }) + + const store = await dumpFunctions([getUser]) + expect(store.definitions.getUser).toBeDefined() + }) + + it('should allow static type function with dump', async () => { + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + dump: { + inputs: [[]], + }, + handler: () => ({ apiUrl: 'https://api.example.com' }), + }) + + const store = await dumpFunctions([getConfig]) + expect(store.definitions.getConfig).toBeDefined() + }) + + it('should allow function without type (defaults to query) with dump', async () => { + const getData = defineRpcFunction({ + name: 'getData', + dump: { + inputs: [[]], + }, + handler: () => 'data', + }) + + const store = await dumpFunctions([getData]) + expect(store.definitions.getData).toBeDefined() + }) + + it('should automatically dump static functions without explicit dump config', async () => { + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + handler: () => ({ apiUrl: 'https://api.example.com', version: 'v1' }), + }) + + const store = await dumpFunctions([getConfig]) + + // Should have definition + expect(store.definitions.getConfig).toBeDefined() + expect(store.definitions.getConfig!.type).toBe('static') + + // Should have one record with empty inputs + const configRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getConfig---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(configRecords.length).toBe(1) + expect(configRecords[0]!.inputs).toEqual([]) + expect(configRecords[0]!.output).toEqual({ apiUrl: 'https://api.example.com', version: 'v1' }) + }) + + it('should use client with static function that has default dump', async () => { + const getVersion = defineRpcFunction({ + name: 'getVersion', + type: 'static', + handler: () => '1.0.0', + }) + + const store = await dumpFunctions([getVersion]) + const client = createClientFromDump(store) + + await expect(client.getVersion()).resolves.toBe('1.0.0') + }) + + it('should respect explicit dump config over default for static functions', async () => { + const getConfigForEnv = defineRpcFunction({ + name: 'getConfigForEnv', + type: 'static', + dump: { + inputs: [['dev'], ['prod']], + }, + handler: (env: string) => ({ env, apiUrl: `https://${env}.api.example.com` }), + }) + + const store = await dumpFunctions([getConfigForEnv]) + + // Should have two records (for dev and prod), not the default empty args + const configRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getConfigForEnv---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(configRecords.length).toBe(2) + expect(configRecords.some(r => r.inputs[0] === 'dev')).toBe(true) + expect(configRecords.some(r => r.inputs[0] === 'prod')).toBe(true) + }) + + it('should support pre-computed records in dump', async () => { + const multiply = defineRpcFunction({ + name: 'multiply', + handler: (a: number, b: number) => a * b, + dump: { + records: [ + { inputs: [2, 3], output: 6 }, + { inputs: [4, 5], output: 20 }, + { inputs: [10, 0], output: 0 }, + ], + }, + }) + + const store = await dumpFunctions([multiply]) + + const multiplyRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('multiply---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(multiplyRecords.length).toBe(3) + expect(multiplyRecords[0]).toMatchObject({ inputs: [2, 3], output: 6 }) + expect(multiplyRecords[1]).toMatchObject({ inputs: [4, 5], output: 20 }) + expect(multiplyRecords[2]).toMatchObject({ inputs: [10, 0], output: 0 }) + + // Verify client works with pre-computed records + const client = createClientFromDump(store) + await expect(client.multiply(2, 3)).resolves.toBe(6) + await expect(client.multiply(4, 5)).resolves.toBe(20) + await expect(client.multiply(10, 0)).resolves.toBe(0) + }) + + it('should support mixing inputs and records', async () => { + const add = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + dump: { + inputs: [[1, 2], [3, 4]], + records: [ + { inputs: [10, 20], output: 30 }, + ], + }, + }) + + const store = await dumpFunctions([add]) + + const addRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('add---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(addRecords.length).toBe(3) + + const client = createClientFromDump(store) + await expect(client.add(1, 2)).resolves.toBe(3) + await expect(client.add(3, 4)).resolves.toBe(7) + await expect(client.add(10, 20)).resolves.toBe(30) + }) + + it('should support error records', async () => { + const divide = defineRpcFunction({ + name: 'divide', + handler: (a: number, b: number) => a / b, + dump: { + records: [ + { inputs: [10, 2], output: 5 }, + { + inputs: [10, 0], + error: { + message: 'Cannot divide by zero', + name: 'Error', + }, + }, + ], + }, + }) + + const store = await dumpFunctions([divide]) + const client = createClientFromDump(store) + + await expect(client.divide(10, 2)).resolves.toBe(5) + await expect(client.divide(10, 0)).rejects.toThrow('Cannot divide by zero') + }) + + it('should support parallel execution', async () => { + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + + const slowAdd = defineRpcFunction({ + name: 'slowAdd', + handler: async (a: number, b: number) => { + await delay(10) + return a + b + }, + dump: { + inputs: [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], + }, + }) + + const startTime = Date.now() + const store = await dumpFunctions([slowAdd], undefined, { concurrency: true }) + const parallelTime = Date.now() - startTime + + // Verify all results are correct + const records = Object.entries(store.records) + .filter(([key]) => key.startsWith('slowAdd---')) + .map(([, record]) => record as RpcDumpRecord) + + expect(records.length).toBe(5) + expect(records[0]).toMatchObject({ inputs: [1, 2], output: 3 }) + expect(records[1]).toMatchObject({ inputs: [3, 4], output: 7 }) + expect(records[2]).toMatchObject({ inputs: [5, 6], output: 11 }) + + // Parallel execution should be faster than sequential (roughly) + // 5 operations * 10ms = 50ms sequential, but parallel should be ~10-20ms + expect(parallelTime).toBeLessThan(40) + }) + + it('should respect concurrency limit', async () => { + let maxConcurrent = 0 + let currentConcurrent = 0 + + const trackedAdd = defineRpcFunction({ + name: 'trackedAdd', + handler: async (a: number, b: number) => { + currentConcurrent++ + maxConcurrent = Math.max(maxConcurrent, currentConcurrent) + await new Promise(resolve => setTimeout(resolve, 10)) + currentConcurrent-- + return a + b + }, + dump: { + inputs: [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], + }, + }) + + await dumpFunctions([trackedAdd], undefined, { + concurrency: 2, + }) + + // With concurrency limit of 2, max concurrent should never exceed 2 + expect(maxConcurrent).toBeLessThanOrEqual(2) + expect(maxConcurrent).toBeGreaterThan(0) + }) +}) diff --git a/packages/devframe/src/rpc/dumps.ts b/packages/devframe/src/rpc/dumps.ts new file mode 100644 index 0000000..b4b978e --- /dev/null +++ b/packages/devframe/src/rpc/dumps.ts @@ -0,0 +1,282 @@ +import type { + BirpcReturn, + RpcDefinitionsToFunctions, + RpcDumpClientOptions, + RpcDumpCollectionOptions, + RpcDumpDefinition, + RpcDumpStore, + RpcFunctionDefinitionAny, +} from './types' +import pLimit from 'p-limit' +import { hash } from '../utils/hash' +import { logger } from './diagnostics' +import { validateDefinitions } from './validation' + +function getDumpRecordKey(functionName: string, args: any[]): string { + const argsHash = hash(args) + return `${functionName}---${argsHash}` +} + +function getDumpFallbackKey(functionName: string): string { + return `${functionName}---fallback` +} + +async function resolveGetter(valueOrGetter: T | (() => Promise)): Promise { + return typeof valueOrGetter === 'function' + ? await (valueOrGetter as () => Promise)() + : valueOrGetter +} + +/** + * Collects pre-computed dumps by executing functions with their defined input combinations. + * Static functions without dump config automatically get `{ inputs: [[]] }`. + * + * @example + * ```ts + * const store = await dumpFunctions([greet], context, { concurrency: 10 }) + * ``` + */ +export async function dumpFunctions< + T extends readonly RpcFunctionDefinitionAny[], +>( + definitions: T, + context?: any, + options: RpcDumpCollectionOptions = {}, +): Promise>> { + validateDefinitions(definitions) + const concurrency = options.concurrency === true + ? 5 + : options.concurrency === false || options.concurrency == null + ? 1 + : options.concurrency + + const store: RpcDumpStore = { + definitions: {}, + records: {}, + } + + // #region Definition resolution + interface TaskResolution { + handler: (...args: any[]) => any + dump: RpcDumpDefinition + definition: RpcFunctionDefinitionAny + } + + const tasksResolutions: (() => Promise)[] = definitions.map(definition => async () => { + if (definition.type === 'event' || definition.type === 'action') { + return undefined + } + + // Fresh setup results for each context to avoid caching issues + const setupResult = definition.setup + ? await Promise.resolve(definition.setup(context)) + : {} + + const handler = setupResult.handler || definition.handler + if (!handler) { + throw logger.DF0024({ name: definition.name }).throw() + } + + let dump = setupResult.dump ?? definition.dump + if (!dump && definition.type === 'static') { + dump = { inputs: [[]] } + } + if (!dump && definition.snapshot) { + // Sugar: run the handler once with no args, store the result as + // both the no-args record and the fallback. Any client call then + // resolves to the same snapshot — matching NMI's "getPayload() + // always returns the baked dump" shape. + dump = async (_ctx, h) => { + const output = await Promise.resolve(h(...([] as unknown as any[]))) + return { + records: [{ inputs: [] as any, output }], + fallback: output, + } + } + } + + if (!dump) { + return undefined + } + + if (typeof dump === 'function') { + dump = await Promise.resolve(dump(context, handler)) + } + + // Only add to definitions if it has a dump + store.definitions[definition.name] = { + name: definition.name, + type: definition.type, + } + + return { + handler, + dump, + definition, + } + }) + + let functionsToDump: TaskResolution[] = [] + if (concurrency <= 1) { + for (const task of tasksResolutions) { + const resolution = await task() + if (resolution) { + functionsToDump.push(resolution) + } + } + } + else { + const limit = pLimit(concurrency) + functionsToDump = (await Promise.all(tasksResolutions.map(task => limit(task)))).filter(x => !!x) + } + // #endregion + + // #region Dump execution + const dumpTasks: Array<() => Promise> = [] + for (const { definition, handler, dump } of functionsToDump) { + const { inputs, records, fallback } = dump + + // Add pre-defined records + if (records) { + for (const record of records) { + const recordKey = getDumpRecordKey(definition.name, record.inputs) + store.records[recordKey] = record + } + } + + // Add fallback record + if ('fallback' in dump) { + const fallbackKey = getDumpFallbackKey(definition.name) + store.records[fallbackKey] = { + inputs: [], + output: fallback, + } + } + + // Add input records execution tasks + if (inputs) { + for (const input of inputs) { + dumpTasks.push(async () => { + const recordKey = getDumpRecordKey(definition.name, input) + + try { + const output = await Promise.resolve(handler(...input)) + store.records[recordKey] = { + inputs: input, + output, + } + } + catch (error: any) { + store.records[recordKey] = { + inputs: input, + error: { + message: error.message, + name: error.name, + }, + } + } + }) + } + } + } + + if (concurrency <= 1) { + for (const task of dumpTasks) { + await task() + } + } + else { + const limit = pLimit(concurrency) + await Promise.all(dumpTasks.map(task => limit(task))) + } + // #endregion + + return store +} + +/** + * Creates a client that serves pre-computed results from a dump store. + * Uses argument hashing to match calls to stored records. + * + * @example + * ```ts + * const client = createClientFromDump(store) + * await client.greet('Alice') + * ``` + */ +export function createClientFromDump>( + store: RpcDumpStore, + options: RpcDumpClientOptions = {}, +): BirpcReturn { + const { onMiss } = options + + const client = new Proxy({} as T, { + get(_, functionName: string) { + if (!(functionName in store.definitions)) { + throw logger.DF0025({ name: functionName }).throw() + } + + return async (...args: any[]) => { + const recordKey = getDumpRecordKey(functionName, args) + + const recordOrGetter = store.records[recordKey] + + if (recordOrGetter) { + const record = await resolveGetter(recordOrGetter) + + if (record.error) { + const error = new Error(record.error.message) + error.name = record.error.name + throw error + } + + if (typeof record.output === 'function') { + return await record.output() + } + + return record.output + } + + onMiss?.(functionName, args) + + const fallbackKey = getDumpFallbackKey(functionName) + if (fallbackKey in store.records) { + const fallbackOrGetter = store.records[fallbackKey] + + const fallbackRecord = await resolveGetter(fallbackOrGetter) + + if (fallbackRecord && typeof fallbackRecord.output === 'function') { + return await fallbackRecord.output() + } + if (fallbackRecord) + return fallbackRecord.output + } + + throw logger.DF0026({ name: functionName, args: JSON.stringify(args) }).throw() + } + }, + has(_, functionName: string) { + return functionName in store.definitions + }, + ownKeys() { + return Object.keys(store.definitions) + }, + getOwnPropertyDescriptor(_, functionName: string) { + return functionName in store.definitions + ? { configurable: true, enumerable: true, value: undefined } + : undefined + }, + }) + + return client as any as BirpcReturn +} + +/** + * Filters function definitions to only those with dump definitions. + * Note: Only checks the definition itself, not setup results. + */ +export function getDefinitionsWithDumps( + definitions: T, +): RpcFunctionDefinitionAny[] { + return definitions.filter(def => def.dump !== undefined) +} diff --git a/packages/devframe/src/rpc/handler.ts b/packages/devframe/src/rpc/handler.ts new file mode 100644 index 0000000..a0a5865 --- /dev/null +++ b/packages/devframe/src/rpc/handler.ts @@ -0,0 +1,48 @@ +import type { RpcFunctionDefinition, RpcFunctionSetupResult, RpcFunctionType } from './types' +import { logger } from './diagnostics' + +export async function getRpcResolvedSetupResult< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + CONTEXT = undefined, +>( + definition: RpcFunctionDefinition, + context: CONTEXT, +): Promise> { + if (definition.__resolved) { + return definition.__resolved + } + if (!definition.setup) { + return {} + } + definition.__promise ??= Promise.resolve(definition.setup(context)) + .then((r) => { + definition.__resolved = r + definition.__promise = undefined + return r + }) + const result = definition.__resolved ??= await definition.__promise + return result +} + +export async function getRpcHandler< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + CONTEXT = undefined, +>( + definition: RpcFunctionDefinition, + context: CONTEXT, +): Promise<(...args: ARGS) => RETURN> { + if (definition.handler) { + return definition.handler + } + const result = await getRpcResolvedSetupResult(definition, context) + if (!result.handler) { + throw logger.DF0024({ name: definition.name }).throw() + } + return result.handler +} diff --git a/packages/devframe/src/rpc/index.ts b/packages/devframe/src/rpc/index.ts new file mode 100644 index 0000000..1a2a1e8 --- /dev/null +++ b/packages/devframe/src/rpc/index.ts @@ -0,0 +1,8 @@ +export * from './cache' +export * from './collector' +export * from './define' +export * from './dumps' +export * from './handler' +export * from './serialization' +export * from './types' +export * from './validation' diff --git a/packages/devframe/src/rpc/serialization.test.ts b/packages/devframe/src/rpc/serialization.test.ts new file mode 100644 index 0000000..8715ba4 --- /dev/null +++ b/packages/devframe/src/rpc/serialization.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { strictJsonStringify } from './serialization' + +describe('strictJsonStringify', () => { + it('matches JSON.stringify for plain JSON values', () => { + const value = { a: 1, b: 'two', c: [true, null, 3.14] } + expect(strictJsonStringify(value)).toBe(JSON.stringify(value)) + }) + + it('rejects Map', () => { + expect(() => strictJsonStringify({ a: new Map([['k', 1]]) }, 'fn')) + .toThrowError(/jsonSerializable: true.*is a Map/) + }) + + it('rejects Set', () => { + expect(() => strictJsonStringify({ a: new Set([1, 2]) }, 'fn')) + .toThrowError(/is a Set/) + }) + + it('rejects Date', () => { + expect(() => strictJsonStringify({ when: new Date() }, 'fn')) + .toThrowError(/is a Date/) + }) + + it('rejects BigInt', () => { + expect(() => strictJsonStringify({ n: 1n }, 'fn')) + .toThrowError(/is a BigInt/) + }) + + it('rejects class instances', () => { + class Thing { + x = 1 + } + expect(() => strictJsonStringify({ t: new Thing() }, 'fn')) + .toThrowError(/is a Thing/) + }) + + it('rejects undefined inside an array (lossy → null in JSON)', () => { + expect(() => strictJsonStringify({ items: [1, undefined, 3] }, 'fn')) + .toThrowError(/is a undefined/) + }) + + it('allows undefined as an object property (legitimate optional field)', () => { + expect(strictJsonStringify({ a: 1, missing: undefined })).toBe('{"a":1}') + }) + + it('allows undefined at the root (action returning nothing)', () => { + expect(strictJsonStringify(undefined)).toBe(undefined as any) + }) + + it('rejects circular references via the native TypeError', () => { + const obj: any = { a: 1 } + obj.self = obj + expect(() => strictJsonStringify(obj, 'fn')) + .toThrowError(/circular|Converting circular/i) + }) + + it('mentions the function name in the diagnostic', () => { + expect(() => strictJsonStringify({ a: new Map() }, 'plugin:my-fn')) + .toThrowError(/plugin:my-fn/) + }) + + it('walks each node only once (single pass)', () => { + const replacerCalls: string[] = [] + const orig = JSON.stringify + const spy = vi.spyOn(JSON, 'stringify').mockImplementation((value, replacer) => { + const wrappedReplacer + = typeof replacer === 'function' + ? function (this: unknown, key: string, val: unknown) { + replacerCalls.push(key) + return (replacer as (k: string, v: unknown) => unknown).call(this, key, val) + } + : replacer + return orig(value, wrappedReplacer as any) + }) + try { + strictJsonStringify({ a: 1, b: { c: [2, 3] } }) + } + finally { + spy.mockRestore() + } + // 1 root + a + b + c + [0] + [1] = 6 nodes + expect(replacerCalls).toEqual(['', 'a', 'b', 'c', '0', '1']) + }) +}) diff --git a/packages/devframe/src/rpc/serialization.ts b/packages/devframe/src/rpc/serialization.ts new file mode 100644 index 0000000..1151ef6 --- /dev/null +++ b/packages/devframe/src/rpc/serialization.ts @@ -0,0 +1,96 @@ +import { logger } from './diagnostics' + +/** + * Wire format used by the WS RPC transport. + * + * - **JSON (default, unprefixed):** payload is plain JSON text. Used when + * the dispatched method is declared `jsonSerializable: true`. Encoded + * via {@link strictJsonStringify} (rejects non-JSON values), decoded + * via `JSON.parse`. + * - **Structured-clone (`s:` prefix):** payload is `s:` followed by + * `structured-clone-es` text. Used when the method is declared + * `jsonSerializable: false` (or omitted, the default). Round-trips + * `Map`, `Set`, `Date`, `BigInt`, cycles, and class instances. + * + * birpc envelopes always start with `{`, so a leading byte that is not + * `s` is unambiguously JSON. Each direction independently chooses its + * encoding from local definitions — request and response are not + * coupled by a mirror rule. + */ +export const STRUCTURED_CLONE_PREFIX = 's:' + +/** + * `JSON.stringify` with a single-pass strict replacer. + * + * Throws `DF0020` synchronously when the value contains a type JSON + * cannot round-trip losslessly: `Map`, `Set`, `Date`, `BigInt`, class + * instances, or `undefined` inside an array (silently becomes `null`). + * + * Native pass-throughs (no extra work needed): + * - circular references — `JSON.stringify` raises `TypeError`. + * - `BigInt` at top level — caught here for a friendlier error path. + * + * Lenient cases (allowed without throwing): + * - `undefined` as an object property — legitimate optional field; + * JSON.stringify just omits it. + * - `undefined` at the root — legitimate "action returned nothing". + * - `Symbol` / `Function` values — semantically "drop me" in JSON. + * + * `fnName` is used only for the diagnostic message — pass the RPC + * function name when calling from a wire serializer / dump writer so + * the error points at the offending function. + */ +export function strictJsonStringify(value: unknown, fnName: string = ''): string { + return JSON.stringify(value, function strictReplacer(this: unknown, key: string, val: unknown): unknown { + // The replacer receives the value AFTER any `toJSON()` coercion + // (e.g. `Date` already became an ISO string). To detect raw types, + // peek at the holder's original property via `this[key]`. At the + // root, `this` is the wrapper `{ '': value }` so `this['']` is the + // raw root value. + const holder = this as Record | unknown[] | undefined + const original = holder != null ? (holder as any)[key] : val + + if (original === undefined) { + if (Array.isArray(holder)) + throw nonJsonAt(fnName, 'undefined', holder, key) + return val + } + if (original === null) + return val + + if (typeof original === 'bigint') + throw nonJsonAt(fnName, 'BigInt', holder, key) + + if (typeof original === 'object') { + if (original instanceof Map) + throw nonJsonAt(fnName, 'Map', holder, key) + if (original instanceof Set) + throw nonJsonAt(fnName, 'Set', holder, key) + if (original instanceof Date) + throw nonJsonAt(fnName, 'Date', holder, key) + if (Array.isArray(original)) + return val + const proto = Object.getPrototypeOf(original) + if (proto !== null && proto !== Object.prototype) { + const ctorName = (original as { constructor?: { name?: string } }).constructor?.name + ?? 'class instance' + throw nonJsonAt(fnName, ctorName, holder, key) + } + } + + return val + }) +} + +function nonJsonAt(fnName: string, type: string, parent: unknown, key: string): Error { + const path = formatPath(parent, key) + return logger.DF0020({ name: fnName || '', type, path }).throw() +} + +function formatPath(parent: unknown, key: string): string { + if (Array.isArray(parent)) + return `[${key}]` + if (key === '') + return '' + return key +} diff --git a/packages/devframe/src/rpc/server.ts b/packages/devframe/src/rpc/server.ts new file mode 100644 index 0000000..d485631 --- /dev/null +++ b/packages/devframe/src/rpc/server.ts @@ -0,0 +1,21 @@ +import type { BirpcGroup, EventOptions } from 'birpc' +import { createBirpcGroup } from 'birpc' + +export function createRpcServer< + ClientFunctions extends object = Record, + ServerFunctions extends object = Record, +>( + functions: ServerFunctions, + options: { + rpcOptions?: EventOptions + } = {}, +): BirpcGroup { + return createBirpcGroup( + functions, + [], + { + ...options.rpcOptions, + proxify: false, + }, + ) +} diff --git a/packages/devframe/src/rpc/transports/ws-client.ts b/packages/devframe/src/rpc/transports/ws-client.ts new file mode 100644 index 0000000..1c388f5 --- /dev/null +++ b/packages/devframe/src/rpc/transports/ws-client.ts @@ -0,0 +1,101 @@ +import type { ChannelOptions } from 'birpc' +import type { RpcFunctionDefinitionAny } from '../types' +import { structuredCloneParse, structuredCloneStringify } from '../../utils/structured-clone' +import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from '../serialization' + +export interface WsRpcChannelOptions { + url: string + onConnected?: (e: Event) => void + onError?: (e: Error) => void + onDisconnected?: (e: CloseEvent) => void + authToken?: string + /** + * RPC function definitions (or just the `jsonSerializable` flag per + * method) used to dispatch the per-call wire serializer. Pass an + * empty / partial map on clients that don't have the full registry — + * encoding falls back to structured-clone (the safer superset) and + * decoding still routes correctly via the wire prefix. + */ + definitions?: ReadonlyMap> +} + +function NOOP() {} + +const EMPTY_DEFS: ReadonlyMap> = new Map() + +/** + * Build a birpc `ChannelOptions` object backed by a browser `WebSocket`. + * Pass the result straight to `createRpcClient`'s `channel` option. + */ +export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions { + let url = options.url + if (options.authToken) { + url = `${url}?vite_devtools_auth_token=${encodeURIComponent(options.authToken)}` + } + const ws = new WebSocket(url) + const { + onConnected = NOOP, + onError = NOOP, + onDisconnected = NOOP, + definitions = EMPTY_DEFS, + } = options + + ws.addEventListener('open', (e) => { + onConnected(e) + }) + + ws.addEventListener('error', (e) => { + const _e = e instanceof Error ? e : new Error(e.type) + onError(_e) + }) + + ws.addEventListener('close', (e) => { + onDisconnected(e) + }) + + // Per-channel state: maps an incoming request id to its method name + // so the matching outgoing response can independently look the + // method up in `definitions` and pick the right encoder. + const pendingRequestMethods = new Map() + return { + on: (handler: (data: string) => void) => { + ws.addEventListener('message', (e) => { + handler(e.data) + }) + }, + post: (data: string) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + else { + function handler() { + ws.send(data) + ws.removeEventListener('open', handler) + } + ws.addEventListener('open', handler) + } + }, + serialize: (msg: any): string => { + let method: string | undefined + if (msg.t === 'q') { + method = msg.m + } + else { + method = pendingRequestMethods.get(msg.i) + pendingRequestMethods.delete(msg.i) + } + const useJson = !!method && definitions.get(method)?.jsonSerializable === true + if (useJson) + return strictJsonStringify(msg, method ?? '') + return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` + }, + deserialize: (raw: string): any => { + const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX) + ? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) + : JSON.parse(raw) + if (msg.t === 'q' && msg.i && msg.m) + pendingRequestMethods.set(msg.i, msg.m) + return msg + }, + } +} diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts new file mode 100644 index 0000000..af8a465 --- /dev/null +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -0,0 +1,162 @@ +import type { BirpcGroup, ChannelOptions } from 'birpc' +import type { IncomingMessage } from 'node:http' +import type { ServerOptions as HttpsServerOptions } from 'node:https' +import type { WebSocket } from 'ws' +import type { RpcFunctionDefinitionAny } from '../types' +import { createServer as createHttpsServer } from 'node:https' +import { WebSocketServer } from 'ws' +import { structuredCloneParse, structuredCloneStringify } from '../../utils/structured-clone' +import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from '../serialization' + +export interface DevToolsNodeRpcSessionMeta { + id: number + ws?: WebSocket + clientAuthToken?: string + isTrusted?: boolean + subscribedStates: Set + /** + * Streams this session has subscribed to via + * `rpc.streaming.subscribe(channel, id)`. Tracked here for O(1) cleanup + * on disconnect; the wire format is `${channel}\x1F${id}`. + */ + subscribedStreams?: Set + /** + * Inbound streams this session is currently uploading to (via + * `rpc.streaming.upload(channel, id)`). Tracked for cleanup on + * disconnect; same wire format as `subscribedStreams`. + */ + uploadingStreams?: Set +} + +export interface WsRpcTransportOptions { + /** Attach to an existing WebSocketServer. When provided, `port`, `host`, and `https` are ignored. */ + wss?: WebSocketServer + /** Port for a newly-created WebSocketServer. */ + port?: number + /** Host for a newly-created WebSocketServer. Defaults to `localhost`. */ + host?: string + /** When set, a new https.Server is created and the WebSocketServer is attached to it. */ + https?: HttpsServerOptions + /** + * RPC function definitions, used by the per-call wire serializer to + * dispatch between strict-JSON and structured-clone encoding based + * on each function's `jsonSerializable` flag. + * + * When omitted, all messages fall back to structured-clone — safe but + * loses dev-time validation for `jsonSerializable: true` declarations. + */ + definitions?: ReadonlyMap> + onConnected?: (ws: WebSocket, req: IncomingMessage, meta: DevToolsNodeRpcSessionMeta) => void + onDisconnected?: (ws: WebSocket, meta: DevToolsNodeRpcSessionMeta) => void + /** Override the default per-call serializer. Most callers should leave this unset. */ + serialize?: ChannelOptions['serialize'] + /** Override the default per-call deserializer. Most callers should leave this unset. */ + deserialize?: ChannelOptions['deserialize'] +} + +let sessionId = 0 + +const EMPTY_DEFS: ReadonlyMap> = new Map() + +function NOOP() {} + +/** + * Attach a WebSocket transport to an existing RPC group. Either pass an + * existing `WebSocketServer` via `wss`, or let this helper create one from + * `port` / `host` / `https`. + */ +export function attachWsRpcTransport< + ClientFunctions extends object, + ServerFunctions extends object, +>( + rpcGroup: BirpcGroup, + options: WsRpcTransportOptions = {}, +): { wss: WebSocketServer } { + const { + wss: externalWss, + port, + host = 'localhost', + https, + onConnected = NOOP, + onDisconnected = NOOP, + definitions = EMPTY_DEFS, + serialize: serializeOverride, + deserialize: deserializeOverride, + } = options + + let wss: WebSocketServer + if (externalWss) { + wss = externalWss + } + else if (https) { + const httpsServer = createHttpsServer(https) + wss = new WebSocketServer({ server: httpsServer }) + httpsServer.listen(port, host) + } + else { + wss = new WebSocketServer({ port, host }) + } + + wss.on('connection', (ws, req) => { + const meta: DevToolsNodeRpcSessionMeta = { + id: sessionId++, + ws, + subscribedStates: new Set(), + } + + // Per-connection state: maps an incoming request id to its method + // name so the matching outgoing response can look the method back + // up in `definitions` and pick the right encoder. One map per WS + // session — request-id spaces don't collide across sessions. + const pendingRequestMethods = new Map() + const channel: ChannelOptions = { + post: (data) => { + ws.send(data) + }, + on: (fn) => { + ws.on('message', (data) => { + fn(data.toString()) + }) + }, + serialize: serializeOverride ?? ((msg: any): string => { + let method: string | undefined + if (msg.t === 'q') { + method = msg.m + } + else { + method = pendingRequestMethods.get(msg.i) + pendingRequestMethods.delete(msg.i) + } + const useJson = !!method && definitions.get(method)?.jsonSerializable === true + if (useJson) + return strictJsonStringify(msg, method ?? '') + return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` + }), + deserialize: deserializeOverride ?? ((raw: string): any => { + const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX) + ? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) + : JSON.parse(raw) + if (msg.t === 'q' && msg.i && msg.m) + pendingRequestMethods.set(msg.i, msg.m) + return msg + }), + meta, + } + + rpcGroup.updateChannels((channels) => { + channels.push(channel) + }) + + ws.on('close', () => { + rpcGroup.updateChannels((channels) => { + const index = channels.indexOf(channel) + if (index >= 0) + channels.splice(index, 1) + }) + onDisconnected(ws, meta) + }) + onConnected(ws, req, meta) + }) + + return { wss } +} diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts new file mode 100644 index 0000000..03321e0 --- /dev/null +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { createRpcClient } from '../client' +import { createRpcServer } from '../server' +import { createWsRpcChannel } from './ws-client' +import { attachWsRpcTransport } from './ws-server' + +vi.stubGlobal('WebSocket', WebSocket) + +describe('devtools rpc', () => { + it('should work w/ ws transport', async () => { + const PORT = 3333 + // Use 127.0.0.1 on both client and server so they agree on the + // address family — `localhost` resolution is ambiguous (IPv4 vs IPv6) + // and differs between Windows/macOS/Linux, which causes the client + // to hang when the two sides pick opposite families. + const HOST = '127.0.0.1' + const WS_URL = `ws://${HOST}:${PORT}` + + const serverFunctions = { + hello: (no: number) => { + return `hello world from client ${no}` + }, + } + + const client1Functions = { + hey: (name: string) => { + return `hey ${name}, I'm client 1` + }, + } + + const client2Functions = { + hey: (name: string) => { + return `hey ${name}, I'm client 2` + }, + } + + const server = createRpcServer(serverFunctions) + attachWsRpcTransport(server, { port: PORT, host: HOST }) + + const client1 = createRpcClient(client1Functions, { + channel: createWsRpcChannel({ url: WS_URL }), + }) + + const client2 = createRpcClient(client2Functions, { + channel: createWsRpcChannel({ url: WS_URL }), + }) + + expect(await client1.$call('hello', 1)).toBe('hello world from client 1') + + expect(await client2.$call('hello', 2)).toBe('hello world from client 2') + + expect(await server.broadcast.$call('hey', 'server')).toEqual(expect.arrayContaining(['hey server, I\'m client 1', 'hey server, I\'m client 2'])) + }) +}) diff --git a/packages/devframe/src/rpc/types.test.ts b/packages/devframe/src/rpc/types.test.ts new file mode 100644 index 0000000..f4a25a8 --- /dev/null +++ b/packages/devframe/src/rpc/types.test.ts @@ -0,0 +1,129 @@ +/* eslint-disable unused-imports/no-unused-vars */ +import type { + RpcDefinitionsToFunctions, + RpcFunctionDefinitionToFunction, +} from '.' +import type { AssertEqual } from './utils' +import * as v from 'valibot' +import { describe, it } from 'vitest' +import { defineRpcFunction } from '.' + +describe('rpcFunctionDefinitionToFunction', () => { + it('should infer types from generic parameters when no schemas', () => { + const fn = defineRpcFunction({ + name: 'noSchema', + handler: (a: string, b: number) => { + return a.length + b + }, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual number> + }) + + it('should infer types from schemas when provided', () => { + const fn = defineRpcFunction({ + name: 'withSchema', + args: [v.string(), v.number()], + returns: v.boolean(), + handler: (a, b) => { + return a.length > b + }, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual boolean> + }) + + it('should infer void return from void schema', () => { + const fn = defineRpcFunction({ + name: 'voidReturn', + args: [v.string()], + returns: v.void(), + handler: (_a) => {}, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual void> + }) + + it('should infer empty args from empty schema', () => { + const fn = defineRpcFunction({ + name: 'noArgs', + args: [], + returns: v.number(), + handler: () => 42, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual number> + }) + + it('should work with setup function instead of handler', () => { + const fn = defineRpcFunction({ + name: 'withSetup', + args: [v.object({ id: v.string() })], + returns: v.array(v.string()), + setup: () => ({ + handler: (input) => { + return [input.id] + }, + }), + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual string[]> + }) +}) + +describe('rpcDefinitionsToFunctions', () => { + it('should map definitions to functions correctly', () => { + const fn1 = defineRpcFunction({ + name: 'getUser', + args: [v.string()], + returns: v.object({ name: v.string() }), + handler: id => ({ name: `User ${id}` }), + }) + + const fn2 = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + }) + + const definitions = [fn1, fn2] as const + + type Result = RpcDefinitionsToFunctions + type _Test = AssertEqual< + Result, + { + getUser: (arg_0: string) => { name: string } + add: (a: number, b: number) => number + } + > + }) + + it('should handle mixed definitions with and without schemas', () => { + const withSchema = defineRpcFunction({ + name: 'withSchema', + args: [v.boolean()], + returns: v.string(), + handler: flag => (flag ? 'yes' : 'no'), + }) + + const withoutSchema = defineRpcFunction({ + name: 'withoutSchema', + handler: (items: string[]) => items.length, + }) + + const definitions = [withSchema, withoutSchema] as const + + type Result = RpcDefinitionsToFunctions + type _Test = AssertEqual< + Result, + { + withSchema: (arg_0: boolean) => string + withoutSchema: (items: string[]) => number + } + > + }) +}) diff --git a/packages/devframe/src/rpc/types.ts b/packages/devframe/src/rpc/types.ts new file mode 100644 index 0000000..b504a36 --- /dev/null +++ b/packages/devframe/src/rpc/types.ts @@ -0,0 +1,330 @@ +import type { GenericSchema } from 'valibot' +import type { InferArgsType, InferReturnType } from './utils' + +export type { BirpcFn, BirpcReturn } from 'birpc' + +export type Thenable = T | Promise + +export type EntriesToObject = { + [K in T[number] as K[0]]: K[1] +} + +/** + * Type of the RPC function, + * - static: A function that returns a static data, no arguments (can be cached and dumped) + * - action: A function that performs an action (no data returned) + * - event: A function that emits an event (no data returned), and does not wait for a response + * - query: A function that queries a resource + * + * By default, the function is a query function. + */ +export type RpcFunctionType = 'static' | 'action' | 'event' | 'query' + +/** + * Agent exposure settings for an RPC function. When this field is set, + * the function is surfaced to agents (e.g. via the devframe MCP adapter) + * as a callable tool. Functions without an `agent` field are not exposed — + * default-deny. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export interface RpcFunctionAgentOptions { + /** + * Human-readable description shown to the agent. Required — agents + * rely on this to decide when to invoke the tool. Keep it to ~1–3 + * sentences explaining what the tool does and when to use it. + */ + description: string + /** + * Optional human-friendly display title. Maps to the MCP tool `title` + * annotation. Falls back to the RPC function `name` when omitted. + */ + title?: string + /** + * Safety classification. Drives MCP annotations (`readOnlyHint`, + * `destructiveHint`) downstream. + * - `'read'` — no side effects; safe to call freely. + * - `'action'` — mutates state but not destructive. + * - `'destructive'` — may perform destructive updates. + * + * When omitted it is inferred from the function `type`: + * - `'static'` / `'query'` → `'read'` + * - `'action'` / `'event'` → `'action'` + */ + safety?: 'read' | 'action' | 'destructive' + /** Free-form tags for grouping or filtering. */ + tags?: readonly string[] + /** + * Optional example invocations shown to agents. Returned verbatim in + * the agent manifest. + */ + examples?: readonly { args: unknown[], description?: string }[] +} + +/** + * Manages dynamic function registration and provides a type-safe proxy for accessing functions. + */ +export interface RpcFunctionsCollector { + /** User-provided context passed to setup functions */ + context: SetupContext + /** Type-safe proxy for calling registered functions */ + readonly functions: LocalFunctions + /** Map of registered function definitions keyed by function name */ + readonly definitions: Map> + /** Register a new function definition */ + register: (fn: RpcFunctionDefinitionAnyWithContext) => void + /** Update an existing function definition */ + update: (fn: RpcFunctionDefinitionAnyWithContext) => void + /** Subscribe to function changes, returns unsubscribe function */ + onChanged: (fn: (id?: string) => void) => (() => void) +} + +/** + * Result returned by a function's setup method. + */ +export interface RpcFunctionSetupResult< + ARGS extends any[], + RETURN = void, +> { + /** Function handler */ + handler?: (...args: ARGS) => RETURN + /** Optional dump definition (overrides definition-level dump) */ + dump?: RpcDumpDefinition +} + +/** Valibot schema array for validating function arguments */ +export type RpcArgsSchema = readonly GenericSchema[] +/** Valibot schema for validating function return value */ +export type RpcReturnSchema = GenericSchema + +/** + * Single record in a dump store with pre-computed results. + */ +export interface RpcDumpRecord { + /** Function arguments */ + inputs: ARGS + /** Result (value or lazy function) */ + output?: RETURN + /** Error if execution failed */ + error?: { + /** Error message */ + message: string + /** Error type name (e.g., "Error", "TypeError") */ + name: string + } +} + +/** + * Defines argument combinations to pre-compute for a function. + */ +export interface RpcDumpDefinition { + /** Argument combinations to pre-compute by executing handler */ + inputs?: ARGS[] + /** Pre-computed records to use directly (bypasses handler execution) */ + records?: RpcDumpRecord[] + /** Fallback value when no match found */ + fallback?: RETURN +} + +/** + * Dynamically generates dump definitions based on context. + */ +export type RpcDumpGetter + = (context: CONTEXT, handler: (...args: ARGS) => RETURN) => Thenable> + +/** + * Dump configuration (static object or dynamic function). + */ +export type RpcDump + = | RpcDumpDefinition + | RpcDumpGetter + +/** + * Base function definition metadata. + */ +export interface RpcFunctionDefinitionBase { + /** Function name (unique identifier) */ + name: string + /** Function type (static, action, event, or query) */ + type?: RpcFunctionType + /** + * Declares whether this function's args/return are JSON-serializable + * — i.e. no `Map`, `Set`, `Date`, `BigInt`, class instances, circular + * references, `undefined` leaves, `Symbol`, or `Function` values. + * + * - `true` — args and return are encoded with strict `JSON.stringify` + * on the wire and on disk. Misshapen values throw `DF0019` at the + * sender, surfacing the bug *during the offending call* rather than + * silently coercing to `{}` later. Required for `agent` exposure. + * - `false` (default) — payloads use `structured-clone-es`, which + * round-trips Maps/Sets/cycles. Functions in this mode cannot be + * exposed via the `agent` field — registration throws `DF0018`. + */ + jsonSerializable?: boolean +} + +/** + * Dump store containing pre-computed results. + * Flat structure for serialization and efficient lookups. + */ +export interface RpcDumpStore { + /** Function definitions keyed by name */ + definitions: Record + /** Records keyed by '---' or '---fallback' */ + records: Record Promise)> + /** @internal */ + _functions?: T +} + +/** + * Dump client options. + */ +export interface RpcDumpClientOptions { + /** Called when arguments don't match any pre-computed entry */ + onMiss?: (functionName: string, args: any[]) => void +} + +/** + * Options for collecting dumps. + */ +export interface RpcDumpCollectionOptions { + /** + * Concurrency control for parallel execution. + * - `false` or `undefined`: sequential execution (default) + * - `true`: parallel execution with concurrency limit of 5 + * - `number`: parallel execution with specified concurrency limit + */ + concurrency?: boolean | number | null +} + +/** + * RPC function definition with optional dump support. + */ +export type RpcFunctionDefinition< + NAME extends string, + TYPE extends RpcFunctionType = 'query', + ARGS extends any[] = [], + RETURN = void, + AS extends RpcArgsSchema | undefined = undefined, + RS extends RpcReturnSchema | undefined = undefined, + CONTEXT = undefined, +> + = [AS, RS] extends [undefined, undefined] + ? { + /** Function name (unique identifier) */ + name: NAME + /** Function type (static, action, event, or query) */ + type?: TYPE + /** Whether the function results should be cached */ + cacheable?: boolean + /** Valibot schema array for validating function arguments */ + args?: AS + /** Valibot schema for validating function return value */ + returns?: RS + /** + * Declares whether this function's args/return are JSON-serializable + * (no Map/Set/Date/BigInt/cycles/class instances/undefined/Symbol/Function). + * + * - `true` — wire and dump use strict `JSON.stringify`; misshapen + * values throw `DF0019` at the call site. Required for `agent`. + * - `false` (default) — `structured-clone-es` round-trips fancy + * types. Cannot be `agent`-exposed (registration throws `DF0018`). + */ + jsonSerializable?: boolean + /** + * Expose this function to agents (e.g. via the MCP adapter). + * When omitted, the function is not agent-exposed (default-deny). + * + * @experimental + */ + agent?: RpcFunctionAgentOptions + /** Setup function called with context to initialize handler and dump */ + setup?: (context: CONTEXT) => Thenable> + /** Function implementation (required if setup doesn't provide one) */ + handler?: (...args: ARGS) => RETURN + /** Dump definition (setup dump takes priority) */ + dump?: RpcDump + /** + * Sugar for "query in dev, single baked snapshot in build": when + * `true` and no `dump` is provided, the build adapter runs the + * handler once with no arguments and stores the result as both a + * no-args record and the fallback so any call variant resolves + * to the same snapshot. Only valid on `query` (or untyped) + * functions — `static` already has equivalent default behavior. + */ + snapshot?: boolean + __resolved?: RpcFunctionSetupResult + __promise?: Thenable> + } + : { + /** Function name (unique identifier) */ + name: NAME + /** Function type (static, action, event, or query) */ + type?: TYPE + /** Whether the function results should be cached */ + cacheable?: boolean + /** Valibot schema array for validating function arguments */ + args: AS + /** Valibot schema for validating function return value */ + returns: RS + /** + * Declares whether this function's args/return are JSON-serializable + * (no Map/Set/Date/BigInt/cycles/class instances/undefined/Symbol/Function). + * + * - `true` — wire and dump use strict `JSON.stringify`; misshapen + * values throw `DF0019` at the call site. Required for `agent`. + * - `false` (default) — `structured-clone-es` round-trips fancy + * types. Cannot be `agent`-exposed (registration throws `DF0018`). + */ + jsonSerializable?: boolean + /** + * Expose this function to agents (e.g. via the MCP adapter). + * When omitted, the function is not agent-exposed (default-deny). + * + * @experimental + */ + agent?: RpcFunctionAgentOptions + /** Setup function called with context to initialize handler and dump */ + setup?: (context: CONTEXT) => Thenable, InferReturnType>> + /** Function implementation (required if setup doesn't provide one) */ + handler?: (...args: InferArgsType) => InferReturnType + /** Dump definition (setup dump takes priority) */ + dump?: RpcDump, InferReturnType, CONTEXT> + /** + * Sugar for "query in dev, single baked snapshot in build": when + * `true` and no `dump` is provided, the build adapter runs the + * handler once with no arguments and stores the result as both a + * no-args record and the fallback so any call variant resolves + * to the same snapshot. Only valid on `query` (or untyped) + * functions — `static` already has equivalent default behavior. + */ + snapshot?: boolean + __resolved?: RpcFunctionSetupResult, InferReturnType> + __promise?: Thenable, InferReturnType>> + } + +export type RpcFunctionDefinitionToFunction + = T extends { args: infer AS, returns: infer RS } + ? AS extends RpcArgsSchema + ? RS extends RpcReturnSchema + ? (...args: InferArgsType) => InferReturnType + : never + : never + : T extends RpcFunctionDefinition + ? (...args: ARGS) => RETURN + : never + +export type RpcFunctionDefinitionAny = RpcFunctionDefinition +export type RpcFunctionDefinitionAnyWithContext = RpcFunctionDefinition + +export type RpcDefinitionsToFunctions = EntriesToObject<{ + [K in keyof T]: [T[K]['name'], RpcFunctionDefinitionToFunction] +}> + +export type RpcDefinitionsFilter< + T extends readonly RpcFunctionDefinitionAny[], + Type extends RpcFunctionType, +> = { + [K in keyof T]: T[K] extends { type: Type } ? T[K] : never +} diff --git a/packages/devframe/src/rpc/utils.ts b/packages/devframe/src/rpc/utils.ts new file mode 100644 index 0000000..18e3fb4 --- /dev/null +++ b/packages/devframe/src/rpc/utils.ts @@ -0,0 +1,24 @@ +import type { GenericSchema, InferInput } from 'valibot' +import type { RpcArgsSchema, RpcReturnSchema } from './types' + +/** Type-level assertion that two types are equal */ +export type AssertEqual + = (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : never + +/** Infers TypeScript tuple type from Valibot schema array */ +export type InferArgsType + = S extends readonly [] ? [] + : S extends readonly [infer H, ...infer T] + ? H extends GenericSchema + ? T extends readonly GenericSchema[] + ? [InferInput, ...InferArgsType] + : never + : never + : never + +/** Infers TypeScript return type from Valibot return schema */ +export type InferReturnType + = S extends RpcReturnSchema + ? InferInput + : void diff --git a/packages/devframe/src/rpc/validation.ts b/packages/devframe/src/rpc/validation.ts new file mode 100644 index 0000000..59a3ee6 --- /dev/null +++ b/packages/devframe/src/rpc/validation.ts @@ -0,0 +1,31 @@ +import type { RpcFunctionDefinitionAny } from './types' +import { logger } from './diagnostics' + +/** + * Validates RPC function definitions. + * Action and event functions cannot have dumps (side effects should not be cached). + * + * @throws {Error} If an action or event function has a dump configuration + */ +export function validateDefinitions(definitions: readonly RpcFunctionDefinitionAny[]): void { + for (const definition of definitions) { + const type = definition.type || 'query' + + if ((type === 'action' || type === 'event') && definition.dump) { + throw logger.DF0027({ name: definition.name, type }).throw() + } + + if (definition.snapshot && type !== 'query') { + throw logger.DF0028({ name: definition.name, type }).throw() + } + } +} + +/** + * Validates a single RPC function definition. + * + * @throws {Error} If an action or event function has a dump configuration + */ +export function validateDefinition(definition: RpcFunctionDefinitionAny): void { + validateDefinitions([definition]) +} diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts new file mode 100644 index 0000000..a966a3a --- /dev/null +++ b/packages/devframe/src/types/agent.ts @@ -0,0 +1,183 @@ +import type { RpcFunctionAgentOptions } from '../rpc/types' +import type { EventEmitter } from './events' + +/** + * Serializable description of an agent-exposed tool. This is the shape + * returned by the agent host manifest and surfaced over the wire by + * the `devframe:agent:list-tools` introspection RPC. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export interface AgentTool { + /** Stable identifier. For RPC-backed tools, matches the RPC name. */ + id: string + /** `'rpc'` when backed by a registered RPC function, `'tool'` when registered via `ctx.agent.registerTool()`. */ + kind: 'rpc' | 'tool' + /** Display title (falls back to `id`). */ + title: string + /** Human-readable description shown to the agent. */ + description: string + /** Safety classification — drives MCP hint annotations downstream. */ + safety: 'read' | 'action' | 'destructive' + /** Free-form tags for grouping/filtering. */ + tags?: readonly string[] + /** Present for `kind === 'rpc'` — points to the RPC function name. */ + rpcName?: string + /** JSON Schema describing the input (positional args synthesized to an object). */ + inputSchema?: unknown + /** JSON Schema describing the output. */ + outputSchema?: unknown + /** Example invocations shown to agents. */ + examples?: readonly { args: unknown[], description?: string }[] +} + +/** + * Input accepted by `DevToolsAgentHost.registerTool()`. Handler is + * stripped from the serializable `AgentTool` projection. + * + * @experimental + */ +export interface AgentToolInput { + id: string + title?: string + description: string + safety?: 'read' | 'action' | 'destructive' + tags?: readonly string[] + inputSchema?: unknown + outputSchema?: unknown + examples?: readonly { args: unknown[], description?: string }[] + /** Invoked when the tool is called. Receives args as provided by the caller. */ + handler: (args: any) => unknown | Promise +} + +/** + * Serializable description of an agent-readable resource. Resources + * surface structured or textual snapshots of devtools state. + * + * @experimental + */ +export interface AgentResource { + id: string + /** URI used by MCP clients. Defaults to `devframe://resource/`. */ + uri: string + name: string + description?: string + /** Defaults to `application/json`. */ + mimeType?: string +} + +/** + * Input accepted by `DevToolsAgentHost.registerResource()`. + * + * @experimental + */ +export interface AgentResourceInput { + id: string + name: string + description?: string + mimeType?: string + /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ + uri?: string + /** Snapshot reader. Called on each read. */ + read: () => Promise | AgentResourceContent +} + +/** + * Payload returned by `AgentResourceInput.read`. Either `text` or `json` must be set. + * + * @experimental + */ +export interface AgentResourceContent { + text?: string + json?: unknown + /** Override the resource's declared mimeType for this read. */ + mimeType?: string +} + +/** + * Unified view of the agent-exposed surface. + * + * @experimental + */ +export interface AgentManifest { + tools: readonly AgentTool[] + resources: readonly AgentResource[] +} + +/** + * Handle returned by `registerTool` / `registerResource`. + * + * @experimental + */ +export interface AgentHandle { + unregister: () => void +} + +/** + * Events emitted by `DevToolsAgentHost`. + * + * @experimental + */ +export interface DevToolsAgentHostEvents { + 'agent:tool:registered': (tool: AgentTool) => void + 'agent:tool:unregistered': (id: string) => void + 'agent:resource:registered': (resource: AgentResource) => void + 'agent:resource:unregistered': (id: string) => void + /** + * Fires when the unified manifest changes — including when a new + * RPC function with an `agent` field is registered on `ctx.rpc`. + */ + 'agent:manifest:changed': () => void +} + +/** + * Host that aggregates the agent-exposed surface of a devtool: both + * RPC functions flagged with `agent` and plugin-registered tools / + * resources. Consumed by protocol adapters such as the devframe MCP + * adapter. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export interface DevToolsAgentHost { + readonly events: EventEmitter + + /** + * Register a tool not backed by an RPC function. Use this when you + * want a plain agent action (e.g. a synthesized summary) that + * shouldn't exist as a full RPC. + */ + registerTool: (tool: AgentToolInput) => AgentHandle + /** Unregister a previously registered tool by id. */ + unregisterTool: (id: string) => boolean + + /** Register a readable resource. */ + registerResource: (resource: AgentResourceInput) => AgentHandle + /** Unregister a previously registered resource by id. */ + unregisterResource: (id: string) => boolean + + /** + * Unified snapshot of agent-exposed surface: RPC functions with an + * `agent` field (auto-discovered from `ctx.rpc.definitions`) plus + * tools/resources registered on the host. + */ + list: () => AgentManifest + + /** + * Invoke any tool by id. Routes to the underlying RPC handler for + * `kind === 'rpc'`, or to the registered handler for `kind === 'tool'`. + */ + invoke: (id: string, args: unknown) => Promise + + /** Read a resource by id. */ + read: (id: string) => Promise + + /** Look up a tool by id (returns the serializable projection). */ + getTool: (id: string) => AgentTool | undefined + /** Look up a resource by id. */ + getResource: (id: string) => AgentResource | undefined +} + +// Re-export the options interface for convenience. +export type { RpcFunctionAgentOptions } diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts new file mode 100644 index 0000000..fbe8465 --- /dev/null +++ b/packages/devframe/src/types/context.ts @@ -0,0 +1,68 @@ +import type { DevToolsAgentHost } from './agent' +import type { DevToolsDiagnosticsHost } from './diagnostics' +import type { DevToolsHost } from './host' +import type { DevToolsViewHost } from './views' + +export interface DevToolsCapabilities { + rpc?: boolean + views?: boolean +} + +/** + * Framework-neutral node context — RPC + diagnostics + agent + the + * view-host (HTTP file-serving). Hub-level subsystems (docks, + * terminals, messages, commands) are not part of this surface; they are + * added by `@vitejs/devtools-kit`'s `createKitContext` when the devtool + * is mounted into a multi-integration hub. + */ +export interface DevToolsNodeContext { + readonly workspaceRoot: string + readonly cwd: string + /** + * Lifecycle distinction surfaced to plugin authors: + * + * - `'dev'` — long-running, interactive session. Connections come and + * go; broadcasts and shared-state mutations are debounced + * to keep the UI responsive. + * - `'build'` — one-shot batch run. The context is set up, the devtool + * collects what it needs, and a snapshot is written. No + * live UI, no WS server. + * + * Names are inherited from Vite's serve/build dichotomy but the meaning + * is general: the same distinction applies to any tool that runs in + * either an interactive or a static-output mode. + */ + readonly mode: 'dev' | 'build' + /** + * Host runtime abstraction — exposes `mountStatic` / `resolveOrigin` / + * `getStorageDir`. + */ + host: DevToolsHost + rpc: import('./rpc').RpcFunctionsHost + views: DevToolsViewHost + /** + * Structured diagnostics host — wraps `logs-sdk` and lets integrations + * register their own coded errors/warnings into the shared logger. + */ + diagnostics: DevToolsDiagnosticsHost + /** + * Agent host — aggregates the agent-exposed surface of this devtool. + * + * @experimental + */ + agent: DevToolsAgentHost +} + +export interface ConnectionMeta { + backend: 'websocket' | 'static' + websocket?: number | string + /** + * Names of RPC functions that have declared `jsonSerializable: true`. + * Used by the WS / static client to dispatch the per-call wire + * serializer (strict JSON for these methods, structured-clone for + * the rest). Populated by the server / build adapter; absent on + * legacy clients, in which case all outgoing messages fall back to + * structured-clone. + */ + jsonSerializableMethods?: string[] +} diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts new file mode 100644 index 0000000..b893a34 --- /dev/null +++ b/packages/devframe/src/types/devframe.ts @@ -0,0 +1,159 @@ +import type { CAC } from 'cac' +import type { CliFlagsSchema } from '../adapters/flags' +import type { DevToolsNodeContext } from './context' + +export type DevframeRuntime = 'cli' | 'build' | 'spa' | 'vite' | 'kit' | 'embedded' + +/** + * Classification of how a devframe is being deployed. Hosted adapters + * (`vite`, `kit`, `embedded`) share their origin with a host app and + * must namespace their mount path under `/__/`. Standalone adapters + * (`cli`, `spa`, `build`) own the origin and default to `/`. + */ +export type DevframeDeploymentKind = 'standalone' | 'hosted' + +export interface DevframeCliOptions { + /** Binary name; default: the devframe's `id`. */ + command?: string + /** Preferred port for the dev server (default 9999). */ + port?: number + /** Port scan range, forwarded to `get-port-please`. */ + portRange?: [number, number] + /** Prefer a random open port. */ + random?: boolean + /** Default host to bind to; `--host` overrides. */ + host?: string + /** + * Auto-open the browser when the dev server starts. + * `true` opens the resolved origin; a string opens that relative path. + * The `--open` / `--no-open` flags override this. + */ + open?: boolean | string + /** + * Skip the RPC trust handshake. Set to `false` for trusted + * single-user localhost tools. Default `true`. + * + * Forwarded to `startHttpAndWs` as a no-op placeholder until devframe + * ships its own auth layer; `@vitejs/devtools` honors the equivalent + * `devtools.clientAuth` today. + */ + auth?: boolean + /** Author's SPA dist directory (served as the devframe's UI). */ + distDir?: string + /** + * Capability-side CAC hook. Called with the CAC instance after the + * adapter registers its built-in commands (`build` / `spa` / `mcp`) + * but before `createCli`'s own `configureCli` caller. Use this to + * contribute tool-specific flags and subcommands from the definition + * itself. + */ + configure?: (cli: CAC) => void + /** + * Typed CLI flags for the default `dev` command, backed by valibot + * schemas. The adapter registers matching `--kebab-key` options on + * CAC, validates the parsed values, and forwards the typed bag to + * `setup(ctx, { flags })`. + * + * Use {@link defineCliFlags} to preserve the literal schema-map + * shape, and {@link InferCliFlags} to recover the typed output at the + * call site: + * + * ```ts + * const appFlags = defineCliFlags({ + * depth: v.pipe(v.number(), v.integer()), + * config: v.optional(v.string()), + * }) + * + * defineDevframe({ + * cli: { flags: appFlags }, + * setup(ctx, info) { + * const flags = info.flags as InferCliFlags + * }, + * }) + * ``` + */ + flags?: CliFlagsSchema +} + +export interface DevframeSpaOptions { + base?: string + /** + * How the deployed SPA loads its data. + * - `'query'` — read from URL search params. + * - `'upload'` — accept a file drag-drop. + * - `'none'` — use the baked RPC dump only. + */ + loader?: 'query' | 'upload' | 'none' +} + +export interface DevframeBrowserContext { + /** + * The connected RPC client (may be write-disabled in static/spa modes). + */ + rpc: unknown +} + +/** + * Runtime information threaded into `setup(ctx, info)`. Adapters + * populate the fields that make sense for their deployment. In + * particular, `createCli` fills `flags` with the parsed CAC bag. + */ +export interface DevframeSetupInfo { + /** Parsed CLI flags, populated by the CLI adapter. */ + flags?: Record +} + +export interface DevframeDefinition { + id: string + name: string + icon?: string | { light: string, dark: string } + version?: string + /** + * Mount path override. Defaults depend on the adapter: + * `/` for standalone (`cli` / `spa` / `build`), `/__/` for hosted + * (`vite` / `kit` / `embedded`). + */ + basePath?: string + capabilities?: { + dev?: boolean | Record + build?: boolean | Record + spa?: boolean | Record + } + /** Server-side setup — the primary entrypoint. Runs in every runtime. */ + setup: (ctx: DevToolsNodeContext, info?: DevframeSetupInfo) => void | Promise + /** Browser-only setup for the SPA adapter (bundled into the client). */ + setupBrowser?: (ctx: DevframeBrowserContext) => void | Promise + cli?: DevframeCliOptions + spa?: DevframeSpaOptions +} + +export function defineDevframe(d: DevframeDefinition): DevframeDefinition { + return d +} + +// --- Deprecated aliases (backward compatibility) --- + +/** @deprecated Use `DevframeRuntime`. */ +export type DevtoolRuntime = DevframeRuntime +/** @deprecated Use `DevframeDeploymentKind`. */ +export type DevtoolDeploymentKind = DevframeDeploymentKind +/** @deprecated Use `DevframeCliOptions`. */ +export type DevtoolCliOptions = DevframeCliOptions +/** @deprecated Use `DevframeSpaOptions`. */ +export type DevtoolSpaOptions = DevframeSpaOptions +/** @deprecated Use `DevframeBrowserContext`. */ +export type DevtoolBrowserContext = DevframeBrowserContext +/** @deprecated Use `DevframeSetupInfo`. */ +export type DevtoolSetupInfo = DevframeSetupInfo +/** @deprecated Use `DevframeDefinition`. */ +export type DevtoolDefinition = DevframeDefinition + +let warnedDefineDevtool = false +/** @deprecated Use `defineDevframe`. */ +export function defineDevtool(d: DevframeDefinition): DevframeDefinition { + if (!warnedDefineDevtool) { + warnedDefineDevtool = true + console.warn('[devframe] `defineDevtool` is deprecated; use `defineDevframe` instead.') + } + return d +} diff --git a/packages/devframe/src/types/diagnostics.ts b/packages/devframe/src/types/diagnostics.ts new file mode 100644 index 0000000..df89e83 --- /dev/null +++ b/packages/devframe/src/types/diagnostics.ts @@ -0,0 +1,71 @@ +import type { createLogger, defineDiagnostics, Logger } from 'logs-sdk' + +/** + * A diagnostics definition object built with `defineDiagnostics`. Typed as + * `unknown` because each integration's definition has a distinct narrow shape + * (e.g. specific code keys like `DF0001` / `MYP0001`), and TypeScript's mapped + * types don't allow assigning a narrow-keyed `DiagnosticsResult` to a + * generically-keyed one. The host stores them in a heterogeneous list and + * rebuilds the logger on `register()`. + */ +export type DevToolsDiagnosticsDefinition = ReturnType> + +/** A `logs-sdk` Logger instance built with `createLogger`. */ +export type DevToolsDiagnosticsLogger = Logger + +/** + * Host for structured diagnostics — a thin layer over `logs-sdk` that lets + * integrations register their own coded errors/warnings into the shared + * logger without taking a direct dependency on `logs-sdk`. + * + * Typical usage from a plugin's `setup(ctx)`: + * + * ```ts + * const myDiagnostics = ctx.diagnostics.defineDiagnostics({ + * docsBase: 'https://example.com/errors', + * codes: { + * MYP0001: { message: 'Something went wrong' }, + * }, + * }) + * ctx.diagnostics.register(myDiagnostics) + * + * // Later (loose typing): + * ctx.diagnostics.logger.MYP0001().warn() + * + * // Or with full typing, keep your own logger reference: + * const logger = ctx.diagnostics.createLogger({ diagnostics: [myDiagnostics] }) + * logger.MYP0001().warn() + * ``` + */ +export interface DevToolsDiagnosticsHost { + /** + * The combined `logs-sdk` Logger including all registered diagnostic + * definitions. The getter always returns the freshest logger — it is + * rebuilt each time `register()` is called, so cached references to + * older loggers will not see codes registered later. + */ + readonly logger: DevToolsDiagnosticsLogger + + /** + * Register an additional diagnostic definition with this host. After + * registration, codes from the new definition are accessible on + * `host.logger`. Accepts any `DiagnosticsResult` produced by + * `defineDiagnostics` — the parameter is typed as `unknown` to avoid + * mapped-type variance issues with narrowly-keyed definitions. + */ + register: (definitions: unknown) => void + + /** + * Re-export of `logs-sdk`'s `defineDiagnostics`. Integrations can build + * their own diagnostic definitions without taking a direct dependency on + * `logs-sdk`. + */ + defineDiagnostics: typeof defineDiagnostics + + /** + * Re-export of `logs-sdk`'s `createLogger`. Use this when an integration + * wants its own typed Logger reference instead of going through + * `host.logger` (which is loosely typed). + */ + createLogger: typeof createLogger +} diff --git a/packages/devframe/src/types/events.ts b/packages/devframe/src/types/events.ts new file mode 100644 index 0000000..b3a323c --- /dev/null +++ b/packages/devframe/src/types/events.ts @@ -0,0 +1,79 @@ +export interface EventsMap { + [event: string]: any +} + +export interface EventUnsubscribe { + (): void +} + +export interface EventEmitter { + /** + * Calls each of the listeners registered for a given event. + * + * ```js + * ee.emit('tick', tickType, tickDuration) + * ``` + * + * @param event The event name. + * @param args The arguments for listeners. + */ + emit: ( + event: K, + ...args: Parameters + ) => void + + /** + * Calls the listeners for a given event once and then removes the listener. + * + * @param event The event name. + * @param args The arguments for listeners. + */ + emitOnce: ( + event: K, + ...args: Parameters + ) => void + + /** + * Event names in keys and arrays with listeners in values. + * + * @internal + */ + _listeners: Partial<{ [E in keyof Events]: Events[E][] }> + + /** + * Add a listener for a given event. + * + * ```js + * const unbind = ee.on('tick', (tickType, tickDuration) => { + * count += 1 + * }) + * + * disable () { + * unbind() + * } + * ``` + * + * @param event The event name. + * @param cb The listener function. + * @returns Unbind listener from event. + */ + on: (event: K, cb: Events[K]) => EventUnsubscribe + /** + * Add a listener for a given event once. + * + * ```js + * const unbind = ee.once('tick', (tickType, tickDuration) => { + * count += 1 + * }) + * + * disable () { + * unbind() + * } + * ``` + * + * @param event The event name. + * @param cb The listener function. + * @returns Unbind listener from event. + */ + once: (event: K, cb: Events[K]) => EventUnsubscribe +} diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts new file mode 100644 index 0000000..70e5cd8 --- /dev/null +++ b/packages/devframe/src/types/host.ts @@ -0,0 +1,46 @@ +// DevToolsHost — abstraction over the runtime that serves the DevTools +// UI and RPC endpoints (Vite dev server, standalone h3 CLI server, static +// snapshot, embedded, etc.). +// +// Host classes (docks, views, ...) call into this interface so they stay +// framework-neutral. Concrete implementations live in each adapter: +// - packages/kit/src/node/vite-host.ts — Vite-backed (dev mode) +// - packages/devframe/src/node/host-h3.ts — h3 CLI server +// - (build/spa/embedded) — added as the respective adapters land + +export interface DevToolsHost { + /** + * Serve a static directory at the given URL base. Called by + * `DevToolsViewHost.hostStatic`. Implementations map this to whatever + * the underlying runtime expects (Vite middleware, h3 handler, no-op + * for build snapshots). + */ + mountStatic: (base: string, distDir: string) => void | Promise + + /** + * Return the public origin the host is reachable at, e.g. + * `http://localhost:5173`. Used by the dock host to enrich remote + * iframe URLs with a full `origin`. Called only when a dock needs an + * absolute URL; hosts that never serve remote docks can return any + * reasonable value. + */ + resolveOrigin: () => string + + /** + * Resolve a directory the host owns for persisted devtools state. + * Each host picks its own app-name namespace so storage doesn't + * collide between, say, the Vite host (`.vite/devtools`) and a + * standalone CLI host (`./devtools`). + * + * - `workspace` — per-project state (settings, caches). Typically + * under `${workspaceRoot}/node_modules/./devtools/`. + * - `global` — per-user state (auth tokens, machine-wide + * preferences). Typically under + * `${homedir()}/./devtools/`. + * + * Implementations should ensure the directory exists or be safe to + * pass to a downstream `createStorage(...)` call that creates it + * lazily. + */ + getStorageDir: (scope: 'workspace' | 'global') => string +} diff --git a/packages/devframe/src/types/index.ts b/packages/devframe/src/types/index.ts new file mode 100644 index 0000000..36dc661 --- /dev/null +++ b/packages/devframe/src/types/index.ts @@ -0,0 +1,10 @@ +export * from './agent' +export * from './context' +export * from './devframe' +export * from './diagnostics' +export * from './events' +export * from './host' +export * from './rpc' +export * from './rpc-augments' +export * from './utils' +export * from './views' diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts new file mode 100644 index 0000000..8d4d5e5 --- /dev/null +++ b/packages/devframe/src/types/rpc-augments.ts @@ -0,0 +1,100 @@ +/** + * To be extended + */ +export interface DevToolsRpcClientFunctions { + /** + * Streaming chunk pushed from server to subscribed clients. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devframe:streaming:chunk': (channel: string, id: string, seq: number, chunk: any) => Promise + /** + * Streaming terminator pushed from server to subscribed clients. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devframe:streaming:end': (channel: string, id: string, error?: { name: string, message: string }) => Promise + /** + * Server→client cancel for an in-flight upload. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devframe:streaming:upload-cancel': (channel: string, id: string) => Promise +} + +/** + * To be extended + */ +export interface DevToolsRpcServerFunctions { + /** + * Subscribe a client to a shared-state key. Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devframe:rpc:server-state:subscribe': (key: string) => Promise + /** + * Read the current value for a shared-state key. Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devframe:rpc:server-state:get': (key: string) => Promise + /** + * Replace a shared-state value (from the client). Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devframe:rpc:server-state:set': (key: string, value: any, syncId: string) => Promise + /** + * Apply a patch to a shared-state value (from the client). Wired by + * `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devframe:rpc:server-state:patch': (key: string, patches: any[], syncId: string) => Promise + /** + * Client→server streaming subscription with optional replay cursor. + * Wired by `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devframe:streaming:subscribe': (channel: string, id: string, opts?: { afterSeq?: number }) => Promise + /** + * Client→server streaming unsubscribe. Wired by `RpcStreamingHost`; + * do not register manually. + * + * @internal + */ + 'devframe:streaming:unsubscribe': (channel: string, id: string) => Promise + /** + * Client→server streaming cancellation request. Wired by + * `RpcStreamingHost`; do not register manually. + * + * @internal + */ + 'devframe:streaming:cancel': (channel: string, id: string) => Promise + /** + * Client→server upload chunk. Wired by `RpcStreamingHost`; do not + * register manually. + * + * @internal + */ + 'devframe:streaming:upload-chunk': (channel: string, id: string, seq: number, chunk: any) => Promise + /** + * Client→server upload terminator. Wired by `RpcStreamingHost`; do not + * register manually. + * + * @internal + */ + 'devframe:streaming:upload-end': (channel: string, id: string, error?: { name: string, message: string }) => Promise +} + +/** + * To be extended + */ +export interface DevToolsRpcSharedStates {} diff --git a/packages/devframe/src/types/rpc.ts b/packages/devframe/src/types/rpc.ts new file mode 100644 index 0000000..6d395e6 --- /dev/null +++ b/packages/devframe/src/types/rpc.ts @@ -0,0 +1,184 @@ +import type { BirpcReturn } from 'birpc' +import type { RpcFunctionsCollectorBase } from 'devframe/rpc' +import type { DevToolsNodeRpcSessionMeta } from 'devframe/rpc/transports/ws-server' +import type { SharedState } from '../utils/shared-state' +import type { StreamReader, StreamSink } from '../utils/streaming-channel' +import type { DevToolsNodeContext } from './context' +import type { DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, DevToolsRpcSharedStates } from './rpc-augments' + +export type { DevToolsNodeRpcSessionMeta } + +export interface DevToolsNodeRpcSession { + meta: DevToolsNodeRpcSessionMeta + rpc: BirpcReturn +} + +export interface RpcBroadcastOptions { + method: METHOD + args: Args + optional?: boolean + event?: boolean + filter?: (client: BirpcReturn) => boolean | void +} + +export type RpcFunctionsHost = RpcFunctionsCollectorBase & { + /** + * Invoke a locally registered server RPC function directly. + * + * This bypasses transport and is useful for server-side cross-function calls. + */ + invokeLocal: < + T extends keyof DevToolsRpcServerFunctions, + Args extends Parameters, + >( + method: T, + ...args: Args + ) => Promise>> + + /** + * Broadcast a message to all connected clients + */ + broadcast: < + T extends keyof DevToolsRpcClientFunctions, + Args extends Parameters, + >( + options: RpcBroadcastOptions, + ) => Promise + + /** + * Get the current RPC client + * + * Available in RPC functions to get the current RPC client + */ + getCurrentRpcSession: () => DevToolsNodeRpcSession | undefined + + /** + * The shared state host + */ + sharedState: RpcSharedStateHost + + /** + * The streaming channel host. Provides per-channel `start()` / + * `pipeFrom()` producers; clients consume via `rpc.streaming.subscribe()`. + * + * @see RpcStreamingHost + */ + streaming: RpcStreamingHost +} + +export interface RpcSharedStateGetOptions { + sharedState?: SharedState + initialValue?: T +} + +export interface RpcSharedStateHost { + get: (key: T, options?: RpcSharedStateGetOptions) => Promise> + keys: () => string[] + /** + * Subscribe to new shared-state keys becoming available. Fires when + * `get(key, ...)` creates a fresh entry (not on subsequent gets). + * Useful for protocol adapters (e.g. MCP) that surface shared state + * as dynamic resources. + */ + onKeyAdded: (fn: (key: string) => void) => () => void +} + +/** + * Options for `RpcStreamingHost.create()`. + */ +export interface RpcStreamingChannelOptions { + /** + * Size of the per-stream ring buffer kept on the server for + * replay-on-resubscribe. `0` (default) disables replay; on reconnect + * the consumer only sees chunks that arrive after subscribing. + * + * The buffer is per stream id, not per channel — each `channel.start()` + * gets its own. + */ + replayWindow?: number + /** + * Milliseconds a closed stream is retained on the server after its + * last subscriber leaves (or if no subscriber ever arrived). During + * this window, late subscribers can still join and replay the buffer + * + receive the `end` frame. + * + * Defaults to `30_000` (30 s) when `replayWindow > 0`, else `0` + * (immediate free). Set to `0` to opt out, or higher for longer + * post-mortem replay. + */ + closedStreamRetention?: number +} + +/** + * Channel handle returned by `ctx.rpc.streaming.create(name, opts)`. A + * channel owns a wire namespace; calling `start()` produces individual + * streams keyed by id. + * + * @see {@link https://devfra.me/guide/streaming Streaming guide} + */ +export interface RpcStreamingChannel { + /** Channel name as registered with `ctx.rpc.streaming.create()`. */ + readonly name: string + /** + * Start a new stream. Returns a server-side sink with both an imperative + * (`write` / `close` / `error`) surface and a `WritableStream` for + * `pipeTo` consumption. The sink's `signal` aborts when every subscriber + * disconnects or cancels. + */ + start: (opts?: { id?: string }) => StreamSink + /** + * Convenience: start a stream and pipe a `ReadableStream` into it. + * The pipe uses `sink.signal` so cancellation propagates upstream. + * + * Node-stream interop: convert a `Readable` with `Readable.toWeb(node)` + * before passing it here. + */ + pipeFrom: (readable: ReadableStream, opts?: { id?: string }) => Promise> + /** Look up an active stream by id. Returns `undefined` if none. */ + get: (id: string) => StreamSink | undefined + /** All active outbound stream ids on this channel. */ + ids: () => string[] + /** + * Open an inbound stream — the server side of a client-to-server + * upload. Allocates an id, returns a `StreamReader` that fills as + * the client writes chunks. Typical pattern is to call this from an + * action handler, kick off background processing, and return the id + * so the caller can start uploading: + * + * ```ts + * handler: async () => { + * const reader = channel.openInbound() + * ;(async () => { + * for await (const chunk of reader) processChunk(chunk) + * })() + * return { uploadId: reader.id } + * } + * ``` + * + * Calling `reader.cancel()` on the server sends an `upload-cancel` to + * the uploading client, which aborts its sink. + */ + openInbound: (opts?: { id?: string }) => StreamReader +} + +/** + * Server-side streaming host. Lives on `ctx.rpc.streaming` alongside + * `ctx.rpc.sharedState`. Each named channel owns its own stream registry + * and wire namespace. + */ +export interface RpcStreamingHost { + /** + * Register a streaming channel. Names follow the `:` + * convention (e.g. `'my-devtool:chat-stream'`). Throws `DF0032` if the + * name is already taken. + */ + create: (name: string, opts?: RpcStreamingChannelOptions) => RpcStreamingChannel + /** + * Adapters call this when a session disconnects so the host can drop + * subscribers and abort orphaned streams. Most users do not need this; + * it's wired by `startHttpAndWs` automatically. + * + * @internal + */ + _onSessionDisconnected: (meta: DevToolsNodeRpcSessionMeta) => void +} diff --git a/packages/devframe/src/types/utils.ts b/packages/devframe/src/types/utils.ts new file mode 100644 index 0000000..58d7bc8 --- /dev/null +++ b/packages/devframe/src/types/utils.ts @@ -0,0 +1,7 @@ +export type Thenable = T | Promise + +export type EntriesToObject = { + [K in T[number] as K[0]]: K[1] +} + +export type PartialWithoutId = Partial> & { id: string } diff --git a/packages/devframe/src/types/views.ts b/packages/devframe/src/types/views.ts new file mode 100644 index 0000000..42ae967 --- /dev/null +++ b/packages/devframe/src/types/views.ts @@ -0,0 +1,12 @@ +export interface DevToolsViewHost { + /** + * @internal + */ + buildStaticDirs: { baseUrl: string, distDir: string }[] + /** + * Helper to host static files + * - In `dev` mode, it will register middleware to `viteServer.middlewares` to host the static files + * - In `build` mode, it will copy the static files to the dist directory + */ + hostStatic: (baseUrl: string, distDir: string) => void +} diff --git a/packages/devframe/src/utils/colors.ts b/packages/devframe/src/utils/colors.ts new file mode 100644 index 0000000..ecea9ff --- /dev/null +++ b/packages/devframe/src/utils/colors.ts @@ -0,0 +1,40 @@ +import ansis from 'ansis' + +/** + * A colorizer — callable as a function (`colors.red('foo')`) or as a + * tagged template (``colors.red`foo ${bar}` ``). + */ +export interface ColorFn { + (text: unknown): string + (template: TemplateStringsArray, ...values: unknown[]): string +} + +/** + * Minimal terminal color palette. Each entry is callable as both a + * plain function and a tagged template. + */ +export interface Colors { + blue: ColorFn + cyan: ColorFn + gray: ColorFn + green: ColorFn + red: ColorFn + yellow: ColorFn + bold: ColorFn + dim: ColorFn + reset: ColorFn + underline: ColorFn +} + +export const colors: Colors = { + blue: ansis.blue, + cyan: ansis.cyan, + gray: ansis.gray, + green: ansis.green, + red: ansis.red, + yellow: ansis.yellow, + bold: ansis.bold, + dim: ansis.dim, + reset: ansis.reset, + underline: ansis.underline, +} diff --git a/packages/devframe/src/utils/events.ts b/packages/devframe/src/utils/events.ts new file mode 100644 index 0000000..7fcb0be --- /dev/null +++ b/packages/devframe/src/utils/events.ts @@ -0,0 +1,56 @@ +import type { EventEmitter, EventsMap, EventUnsubscribe } from 'devframe/types' + +/** + * Create event emitter. + */ +export function createEventEmitter< + Events extends EventsMap, +>(): EventEmitter { + const _listeners: Partial<{ [E in keyof Events]: Events[E][] }> = {} + + function emit( + event: K, + ...args: Parameters + ) { + const callbacks = _listeners[event] || [] + for (let i = 0, length = callbacks.length; i < length; i++) { + const callback = callbacks[i] + if (callback) + callback(...args) + } + } + function emitOnce( + event: K, + ...args: Parameters + ) { + emit(event, ...args) + delete _listeners[event] + } + function on( + event: K, + cb: Events[K], + ): EventUnsubscribe { + ;(_listeners[event] ||= [] as Events[K][]).push(cb) + return () => { + _listeners[event] = _listeners[event]?.filter(i => cb !== i) as Events[K][] | undefined + } + } + function once( + event: K, + cb: Events[K], + ) { + const unsubscribe = on(event, ((...args: Parameters) => { + unsubscribe() + return cb(...args) + }) as Events[K]) + return unsubscribe + } + + return { + _listeners, + emit, + emitOnce, + on, + once, + } +} diff --git a/packages/devframe/src/utils/hash.ts b/packages/devframe/src/utils/hash.ts new file mode 100644 index 0000000..d920294 --- /dev/null +++ b/packages/devframe/src/utils/hash.ts @@ -0,0 +1,8 @@ +import { hash as hashImpl } from 'ohash' + +/** + * Stable, deterministic hash of any structured-cloneable value. + */ +export function hash(value: unknown): string { + return hashImpl(value) +} diff --git a/packages/devframe/src/utils/human-id.ts b/packages/devframe/src/utils/human-id.ts new file mode 100644 index 0000000..fb71b15 --- /dev/null +++ b/packages/devframe/src/utils/human-id.ts @@ -0,0 +1,9 @@ +import { humanId as humanIdImpl } from 'human-id' + +/** + * Generate a human-readable, lowercase, dash-separated random ID + * (e.g. `bright-orange-tiger`). + */ +export function humanId(): string { + return humanIdImpl({ separator: '-', capitalize: false }) +} diff --git a/packages/devframe/src/utils/launch-editor.ts b/packages/devframe/src/utils/launch-editor.ts new file mode 100644 index 0000000..864b955 --- /dev/null +++ b/packages/devframe/src/utils/launch-editor.ts @@ -0,0 +1,14 @@ +import launchImpl from 'launch-editor' + +/** + * Open a file in the user's editor. + * + * `target` may be a plain path, `file:line`, or `file:line:column`. + * + * If `editor` is provided, it is used as the editor command (e.g. `'code'`, + * `'subl'`) or absolute binary path. Otherwise the editor is auto-detected + * via the `LAUNCH_EDITOR` env var with a fallback to common defaults. + */ +export function launchEditor(target: string, editor?: string): void { + launchImpl(target, editor) +} diff --git a/packages/devframe/src/utils/nanoid.ts b/packages/devframe/src/utils/nanoid.ts new file mode 100644 index 0000000..f6c7eab --- /dev/null +++ b/packages/devframe/src/utils/nanoid.ts @@ -0,0 +1,10 @@ +// port from nanoid +// https://github.com/ai/nanoid +const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict' +export function nanoid(size = 21) { + let id = '' + let i = size + while (i--) + id += urlAlphabet[(Math.random() * 64) | 0] + return id +} diff --git a/packages/devframe/src/utils/open.ts b/packages/devframe/src/utils/open.ts new file mode 100644 index 0000000..d673cfa --- /dev/null +++ b/packages/devframe/src/utils/open.ts @@ -0,0 +1,18 @@ +import openImpl from 'open' + +export interface OpenOptions { + /** + * Resolve only after the launched app exits. + * + * @default false + */ + wait?: boolean +} + +/** + * Open a URL, file, or other target in its default OS handler + * (browser for URLs, Finder/Explorer for paths, etc.). + */ +export async function open(target: string, options?: OpenOptions): Promise { + await openImpl(target, options) +} diff --git a/packages/devframe/src/utils/promise.ts b/packages/devframe/src/utils/promise.ts new file mode 100644 index 0000000..87bef2e --- /dev/null +++ b/packages/devframe/src/utils/promise.ts @@ -0,0 +1,17 @@ +export function promiseWithResolver(): { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} { + let resolve: (value: T) => void | undefined + let reject: (error: Error) => void | undefined + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + return { + promise, + resolve: resolve!, + reject: reject!, + } +} diff --git a/packages/devframe/src/utils/serve-static.test.ts b/packages/devframe/src/utils/serve-static.test.ts new file mode 100644 index 0000000..82b01a3 --- /dev/null +++ b/packages/devframe/src/utils/serve-static.test.ts @@ -0,0 +1,209 @@ +import type { AddressInfo } from 'node:net' +import type { ServeStaticOptions } from './serve-static' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createApp, toNodeListener } from 'h3' +import { afterEach, describe, expect, it } from 'vitest' +import { serveStaticHandler, serveStaticNodeMiddleware } from './serve-static' + +interface Fixture { + dir: string + baseUrl: string + close: () => Promise +} + +function makeTmp(prefix = 'devframe-serve-'): string { + return mkdtempSync(join(tmpdir(), prefix)) +} + +async function startH3(dir: string, options?: ServeStaticOptions): Promise { + const app = createApp() + app.use(serveStaticHandler(dir, options)) + const server = createServer(toNodeListener(app)) + await new Promise(r => server.listen(0, '127.0.0.1', r)) + const port = (server.address() as AddressInfo).port + return { + dir, + baseUrl: `http://127.0.0.1:${port}`, + close: () => new Promise(r => server.close(() => r())), + } +} + +async function startMw( + dir: string, + options?: ServeStaticOptions, + next: (req: { url?: string }, res: import('node:http').ServerResponse) => void = (_req, res) => { + res.statusCode = 404 + res.end('next-fallback') + }, +): Promise { + const mw = serveStaticNodeMiddleware(dir, options) + const server = createServer((req, res) => { + mw(req, res, () => next(req, res)) + }) + await new Promise(r => server.listen(0, '127.0.0.1', r)) + const port = (server.address() as AddressInfo).port + return { + dir, + baseUrl: `http://127.0.0.1:${port}`, + close: () => new Promise(r => server.close(() => r())), + } +} + +describe('serveStaticHandler', () => { + let fx: Fixture | undefined + + afterEach(async () => { + await fx?.close() + fx = undefined + }) + + it('serves a direct file hit with correct Content-Type', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'app.js'), 'console.log("hi")', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/app.js`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toMatch(/javascript/) + expect(res.headers.get('cache-control')).toBe('no-store') + expect(await res.text()).toBe('console.log("hi")') + }) + + it('serves index.html for a directory request with HTML charset', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'index.html'), 'root', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(await res.text()).toBe('root') + }) + + it('falls back to index.html for an extension-less miss (SPA routing)', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'index.html'), 'spa', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/users/42`) + expect(res.status).toBe(200) + expect(await res.text()).toBe('spa') + }) + + it('returns 404 for an asset-looking miss instead of SPA-falling back', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'index.html'), 'spa', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/nonexistent.js`) + expect(res.status).toBe(404) + }) + + it('returns 404 when SPA fallback has no index to serve', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'app.js'), 'x', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/some/deep/route`) + expect(res.status).toBe(404) + }) + + it('rejects encoded path-traversal attempts', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'index.html'), 'ok', 'utf-8') + fx = await startH3(dir) + + // Encoded `..` segments survive URL construction; the path-traversal + // guard rejects the resolved absolute path, and the `.js` suffix + // disables SPA fallback so this returns 404 (not index.html). + const res = await fetch(`${fx.baseUrl}/..%2F..%2F..%2Fetc%2Fpasswd.js`) + expect(res.status).toBe(404) + }) + + it('responds to HEAD with headers but no body', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'app.js'), 'console.log("body")', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/app.js`, { method: 'HEAD' }) + expect(res.status).toBe(200) + expect(res.headers.get('content-length')).toBe('19') + expect(await res.text()).toBe('') + }) + + it('rejects POST with 405', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'index.html'), 'ok', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/`, { method: 'POST', body: 'x' }) + expect(res.status).toBe(405) + expect(res.headers.get('allow')).toBe('GET, HEAD') + }) + + it('decodes percent-encoded paths', async () => { + const dir = makeTmp() + mkdirSync(join(dir, 'has space')) + writeFileSync(join(dir, 'has space', 'file.txt'), 'spaced', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/has%20space/file.txt`) + expect(res.status).toBe(200) + expect(await res.text()).toBe('spaced') + }) + + it('resolves extension-less paths via .html (sirv extensions parity)', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'about.html'), 'about', 'utf-8') + writeFileSync(join(dir, 'index.html'), 'root', 'utf-8') + fx = await startH3(dir) + + const res = await fetch(`${fx.baseUrl}/about`) + expect(res.status).toBe(200) + expect(await res.text()).toBe('about') + }) +}) + +describe('serveStaticNodeMiddleware', () => { + let fx: Fixture | undefined + + afterEach(async () => { + await fx?.close() + fx = undefined + }) + + it('serves files inside a Connect-style next chain', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'app.js'), 'console.log("mw")', 'utf-8') + fx = await startMw(dir) + + const res = await fetch(`${fx.baseUrl}/app.js`) + expect(res.status).toBe(200) + expect(await res.text()).toBe('console.log("mw")') + }) + + it('calls next() on miss when SPA fallback is disabled', async () => { + const dir = makeTmp() + fx = await startMw(dir, { single: false }, (_req, res) => { + res.statusCode = 418 + res.end('teapot') + }) + + const res = await fetch(`${fx.baseUrl}/missing.js`) + expect(res.status).toBe(418) + expect(await res.text()).toBe('teapot') + }) + + it('falls back to index.html on SPA routes (matches handler behavior)', async () => { + const dir = makeTmp() + writeFileSync(join(dir, 'index.html'), 'mw-spa', 'utf-8') + fx = await startMw(dir) + + const res = await fetch(`${fx.baseUrl}/some/deep/path`) + expect(res.status).toBe(200) + expect(await res.text()).toBe('mw-spa') + }) +}) diff --git a/packages/devframe/src/utils/serve-static.ts b/packages/devframe/src/utils/serve-static.ts new file mode 100644 index 0000000..7c49e2a --- /dev/null +++ b/packages/devframe/src/utils/serve-static.ts @@ -0,0 +1,216 @@ +import type { EventHandler, EventHandlerRequest } from 'h3' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { defineEventHandler, sendStream, setResponseHeader, setResponseStatus } from 'h3' +import { lookup } from 'mrmime' +import { extname, join, normalize, resolve, sep } from 'pathe' + +export interface ServeStaticOptions { + /** Default: `['index.html']`. */ + indexNames?: string[] + /** SPA fallback to `indexNames[0]` on miss. Default: `true`. */ + single?: boolean +} + +interface ResolvedFile { + abs: string + size: number + mtime: Date +} + +const HTML_EXTENSIONS = ['.html', '.htm'] + +async function statFile(abs: string): Promise { + try { + const s = await stat(abs) + if (!s.isFile()) + return null + return { abs, size: s.size, mtime: s.mtime } + } + catch { + return null + } +} + +async function resolveTarget( + absDir: string, + urlPath: string, + indexNames: string[], + single: boolean, +): Promise { + let cleaned: string + try { + cleaned = decodeURIComponent(urlPath || '/') + } + catch { + return null + } + cleaned = cleaned.replace(/[?#].*$/, '') + if (cleaned.endsWith('/')) + cleaned = cleaned.slice(0, -1) + if (cleaned.startsWith('/')) + cleaned = cleaned.slice(1) + + const abs = normalize(join(absDir, cleaned)) + + if (abs !== absDir && !abs.startsWith(absDir + sep)) + return null + + const direct = await statFile(abs) + if (direct) + return direct + + try { + const s = await stat(abs) + if (s.isDirectory()) { + for (const name of indexNames) { + const candidate = await statFile(join(abs, name)) + if (candidate) + return candidate + } + } + } + catch { + // not found / not a directory — continue + } + + // Mirror sirv's `extensions: ['html', 'htm']` default: when the request + // has no file extension, try `${path}.html` / `${path}.htm` before SPA + // fallback so pretty-URL deployments resolve to the right page. + if (!extname(cleaned)) { + for (const ext of HTML_EXTENSIONS) { + const candidate = await statFile(abs + ext) + if (candidate) + return candidate + } + } + + const fallbackIndex = indexNames[0] + if (single && fallbackIndex && !/\.[a-z0-9]+$/i.test(cleaned)) { + const indexFile = await statFile(join(absDir, fallbackIndex)) + if (indexFile) + return indexFile + } + + return null +} + +function contentTypeFor(abs: string): string { + const type = lookup(abs) + if (!type) + return 'application/octet-stream' + if (type === 'text/html') + return 'text/html; charset=utf-8' + return type +} + +function setStaticHeaders(res: ServerResponse, file: ResolvedFile): void { + res.setHeader('Content-Type', contentTypeFor(file.abs)) + res.setHeader('Content-Length', file.size) + res.setHeader('Last-Modified', file.mtime.toUTCString()) + res.setHeader('Cache-Control', 'no-store') +} + +interface NormalizedOptions { + indexNames: string[] + single: boolean +} + +function normalizeOptions(options: ServeStaticOptions | undefined): NormalizedOptions { + return { + indexNames: options?.indexNames ?? ['index.html'], + single: options?.single ?? true, + } +} + +/** + * h3 event handler that serves files from `dir` with SPA fallback. + * + * Drop-in replacement for `fromNodeMiddleware(sirv(dir, { dev: true, single: true }))` + * when the surrounding server is an h3 app — no `Cache-Control` beyond + * `no-store`, `Content-Type` resolved via `mrmime`, and a miss with no + * file extension falls back to `/index.html` so client-side routing + * works. + */ +export function serveStaticHandler( + dir: string, + options?: ServeStaticOptions, +): EventHandler { + const absDir = resolve(dir) + const opts = normalizeOptions(options) + return defineEventHandler(async (event) => { + const method = event.node.req.method + if (method !== 'GET' && method !== 'HEAD') { + setResponseStatus(event, 405) + setResponseHeader(event, 'Allow', 'GET, HEAD') + return '' + } + const url = event.node.req.url ?? '/' + const file = await resolveTarget(absDir, url, opts.indexNames, opts.single) + if (!file) { + setResponseStatus(event, 404) + return '' + } + setStaticHeaders(event.node.res, file) + if (method === 'HEAD') { + event.node.res.end() + return '' + } + return sendStream(event, createReadStream(file.abs)) + }) +} + +/** + * Connect/Express-style Node middleware variant of {@link serveStaticHandler}. + * + * Use when mounting onto `viteServer.middlewares.use(base, …)` or any other + * Connect stack — avoids forcing the host package to depend on h3 just to + * adapt an event handler back into Node middleware. + */ +export function serveStaticNodeMiddleware( + dir: string, + options?: ServeStaticOptions, +): (req: IncomingMessage, res: ServerResponse, next?: (err?: Error) => void) => void { + const absDir = resolve(dir) + const opts = normalizeOptions(options) + return (req, res, next) => { + void (async () => { + const method = req.method + if (method !== 'GET' && method !== 'HEAD') { + if (next) { + next() + return + } + res.statusCode = 405 + res.setHeader('Allow', 'GET, HEAD') + res.end() + return + } + const url = req.url ?? '/' + const file = await resolveTarget(absDir, url, opts.indexNames, opts.single) + if (!file) { + if (next) { + next() + return + } + res.statusCode = 404 + res.end() + return + } + setStaticHeaders(res, file) + if (method === 'HEAD') { + res.end() + return + } + createReadStream(file.abs).pipe(res) + })().catch((err: unknown) => { + if (next) { + next(err instanceof Error ? err : new Error(String(err))) + return + } + res.statusCode = 500 + res.end() + }) + } +} diff --git a/packages/devframe/src/utils/shared-state.test.ts b/packages/devframe/src/utils/shared-state.test.ts new file mode 100644 index 0000000..cc4a681 --- /dev/null +++ b/packages/devframe/src/utils/shared-state.test.ts @@ -0,0 +1,343 @@ +import type { SharedStatePatch } from './shared-state' +import { describe, expect, it, vi } from 'vitest' +import { createSharedState } from './shared-state' + +describe('shared-state', () => { + describe('immutability', () => { + it('should return immutable state from get()', () => { + const state = createSharedState({ + initialValue: { count: 0, items: ['a', 'b'] }, + }) + + const currentState = state.value() + + // State should be readonly at type level + // Runtime: mutations should not affect internal state when done through proper API + // The state returned is a reference to the internal state, but mutations + // must go through mutate() to change the internal state + expect(currentState.count).toBe(0) + expect(state.value().count).toBe(0) + }) + + it('should not mutate original state when mutating returned state directly', () => { + const state = createSharedState({ + initialValue: { count: 0, nested: { value: 'test' } }, + }) + + const state1 = state.value() + + // Mutate through proper API + state.mutate((draft) => { + draft.count = 999 + draft.nested.value = 'changed' + }) + + // Original reference should be different from new state + const state2 = state.value() + expect(state1).not.toBe(state2) + expect(state1.count).toBe(0) + expect(state1.nested.value).toBe('test') + expect(state2.count).toBe(999) + expect(state2.nested.value).toBe('changed') + }) + + it('should return new immutable state after mutation', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + }) + + const state1 = state.value() + state.mutate((draft) => { + draft.count = 1 + }) + const state2 = state.value() + + // States should be different objects + expect(state1).not.toBe(state2) + expect(state1.count).toBe(0) + expect(state2.count).toBe(1) + }) + + it('should maintain immutability for nested objects', () => { + const state = createSharedState({ + initialValue: { + user: { + name: 'Alice', + profile: { + age: 30, + }, + }, + }, + }) + + const state1 = state.value() + + // Mutate through proper API + state.mutate((draft) => { + draft.user.name = 'Bob' + draft.user.profile.age = 31 + }) + + const state2 = state.value() + + // Original state should be unchanged + expect(state1.user.name).toBe('Alice') + expect(state1.user.profile.age).toBe(30) + // New state should have changes + expect(state2.user.name).toBe('Bob') + expect(state2.user.profile.age).toBe(31) + // References should be different + expect(state1).not.toBe(state2) + expect(state1.user).not.toBe(state2.user) + expect(state1.user.profile).not.toBe(state2.user.profile) + }) + + it('should maintain immutability for arrays', () => { + const state = createSharedState({ + initialValue: { items: [1, 2, 3] }, + }) + + const state1 = state.value() + + // Mutate through proper API + state.mutate((draft) => { + draft.items.push(4) + draft.items[0] = 999 + }) + + const state2 = state.value() + + // Original state should be unchanged + expect(state1.items).toEqual([1, 2, 3]) + // New state should have changes + expect(state2.items).toEqual([999, 2, 3, 4]) + // References should be different + expect(state1).not.toBe(state2) + expect(state1.items).not.toBe(state2.items) + }) + }) + + describe('sync dead loop prevention', () => { + it('should prevent duplicate mutations with same syncId', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + }) + + const syncId = 'test-sync-id' + + // First mutation should succeed + state.mutate((draft) => { + draft.count = 1 + }, syncId) + + expect(state.value().count).toBe(1) + expect(state.syncIds.has(syncId)).toBe(true) + + // Second mutation with same syncId should be ignored + state.mutate((draft) => { + draft.count = 2 + }, syncId) + + // State should remain unchanged + expect(state.value().count).toBe(1) + }) + + it('should prevent duplicate patches with same syncId', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + enablePatches: true, + }) + + const syncId = 'test-sync-id' + const patches: SharedStatePatch[] = [ + { + op: 'replace', + path: ['count'], + value: 10, + }, + ] + + // First patch should succeed + state.patch(patches, syncId) + + expect(state.value().count).toBe(10) + expect(state.syncIds.has(syncId)).toBe(true) + + // Second patch with same syncId should be ignored + const patches2: SharedStatePatch[] = [ + { + op: 'replace', + path: ['count'], + value: 20, + }, + ] + state.patch(patches2, syncId) + + // State should remain unchanged + expect(state.value().count).toBe(10) + }) + + it('should allow different mutations with different syncIds', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + }) + + state.mutate((draft) => { + draft.count = 1 + }, 'sync-1') + + expect(state.value().count).toBe(1) + + state.mutate((draft) => { + draft.count = 2 + }, 'sync-2') + + expect(state.value().count).toBe(2) + expect(state.syncIds.has('sync-1')).toBe(true) + expect(state.syncIds.has('sync-2')).toBe(true) + }) + + it('should prevent sync loop when mutate triggers event listener that mutates again', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + }) + + const syncId = 'loop-sync-id' + const listener = vi.fn() + + state.on('updated', (fullState, patches, receivedSyncId) => { + listener(fullState, patches, receivedSyncId) + // Try to mutate again with the same syncId (should be prevented) + state.mutate((draft) => { + draft.count = 999 + }, syncId) + }) + + // Initial mutation + state.mutate((draft) => { + draft.count = 1 + }, syncId) + + // Listener should be called once + expect(listener).toHaveBeenCalledTimes(1) + // State should be 1, not 999 (loop prevented) + expect(state.value().count).toBe(1) + }) + + it('should prevent sync loop when patch triggers event listener that patches again', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + enablePatches: true, + }) + + const syncId = 'loop-sync-id' + const listener = vi.fn() + + state.on('updated', (fullState, patches, receivedSyncId) => { + listener(fullState, patches, receivedSyncId) + // Try to patch again with the same syncId (should be prevented) + state.patch([ + { + op: 'replace', + path: ['count'], + value: 999, + }, + ], syncId) + }) + + // Initial patch + state.patch([ + { + op: 'replace', + path: ['count'], + value: 1, + }, + ], syncId) + + // Listener should be called once + expect(listener).toHaveBeenCalledTimes(1) + // State should be 1, not 999 (loop prevented) + expect(state.value().count).toBe(1) + }) + + it('should generate unique syncIds when not provided', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + }) + + // Multiple mutations without syncId should all succeed + state.mutate((draft) => { + draft.count = 1 + }) + state.mutate((draft) => { + draft.count = 2 + }) + state.mutate((draft) => { + draft.count = 3 + }) + + expect(state.value().count).toBe(3) + // Each mutation should have generated a unique syncId + expect(state.syncIds.size).toBe(3) + }) + + it('should track syncIds correctly', () => { + const state = createSharedState({ + initialValue: { count: 0 }, + }) + + expect(state.syncIds.size).toBe(0) + + state.mutate((draft) => { + draft.count = 1 + }, 'sync-1') + + expect(state.syncIds.size).toBe(1) + expect(state.syncIds.has('sync-1')).toBe(true) + + state.mutate((draft) => { + draft.count = 2 + }, 'sync-2') + + expect(state.syncIds.size).toBe(2) + expect(state.syncIds.has('sync-1')).toBe(true) + expect(state.syncIds.has('sync-2')).toBe(true) + }) + + it('should able to sync between two shared states', () => { + const state1 = createSharedState({ + initialValue: { count: 0 }, + }) + const state2 = createSharedState({ + initialValue: { count: 10 }, + }) + + const clone = (s: T): T => JSON.parse(JSON.stringify(s)) as T + + state1.on('updated', (fullState, _patches, syncId) => { + state2.mutate(() => clone(fullState), syncId) + }) + + state2.on('updated', (fullState, _patches, syncId) => { + state1.mutate(() => clone(fullState), syncId) + }) + + expect(state1.value().count).toBe(0) + expect(state2.value().count).toBe(10) + + state1.mutate((draft) => { + draft.count = 1 + }) + + expect(state2.value().count).toBe(1) + expect(state1.value().count).toBe(1) + + state2.mutate((draft) => { + draft.count += 1 + }) + + expect(state1.value().count).toBe(2) + expect(state2.value().count).toBe(2) + }) + }) +}) diff --git a/packages/devframe/src/utils/shared-state.ts b/packages/devframe/src/utils/shared-state.ts new file mode 100644 index 0000000..ee75135 --- /dev/null +++ b/packages/devframe/src/utils/shared-state.ts @@ -0,0 +1,118 @@ +import type { EventEmitter } from 'devframe/types' +import type { Objectish, Patch } from 'immer' +import { applyPatches, enablePatches as enableImmerPatches, produce, produceWithPatches } from 'immer' +import { createEventEmitter } from './events' +import { nanoid } from './nanoid' + +// eslint-disable-next-line ts/no-unsafe-function-type +type ImmutablePrimitive = undefined | null | boolean | string | number | Function + +export type Immutable + = T extends ImmutablePrimitive ? T + : T extends Array ? ImmutableArray + : T extends Map ? ImmutableMap + : T extends Set ? ImmutableSet : ImmutableObject + +export type ImmutableArray = ReadonlyArray> +export type ImmutableMap = ReadonlyMap, Immutable> +export type ImmutableSet = ReadonlySet> +export type ImmutableObject = { readonly [K in keyof T]: Immutable } + +/** + * Serializable patch describing a single mutation to a `SharedState`. + * Structurally compatible with JSON-Patch and is safe to send over RPC. + */ +export interface SharedStatePatch { + op: 'add' | 'remove' | 'replace' + path: readonly (string | number)[] + value?: unknown +} + +/** + * State host that is immutable by default with explicit mutate. + */ +export interface SharedState { + /** + * Get the current state. Immutable. + */ + value: () => Immutable + /** + * Subscribe to state changes. + */ + on: EventEmitter>['on'] + /** + * Mutate the state. + */ + mutate: (fn: (state: T) => void, syncId?: string) => void + /** + * Apply patches to the state. + */ + patch: (patches: SharedStatePatch[], syncId?: string) => void + /** + * Sync IDs that have been applied to the state. + */ + syncIds: Set +} + +export interface SharedStateEvents { + updated: (fullState: T, patches: SharedStatePatch[] | undefined, syncId: string) => void +} + +export interface SharedStateOptions { + /** + * Initial state. + */ + initialValue: T + /** + * Enable patches. + * + * @default false + */ + enablePatches?: boolean +} + +export function createSharedState( + options: SharedStateOptions, +): SharedState { + const { + enablePatches = false, + } = options + + const events = createEventEmitter>() + let state = options.initialValue + const syncIds = new Set() + + return { + on: events.on, + value: () => state as Immutable, + patch: (patches: SharedStatePatch[], syncId = nanoid()) => { + // Avoid loop syncs + if (syncIds.has(syncId)) + return + enableImmerPatches() + state = applyPatches(state as unknown as Objectish, patches as unknown as Patch[]) as T + syncIds.add(syncId) + events.emit('updated', state, undefined, syncId) + }, + mutate: (fn, syncId = nanoid()) => { + // Avoid loop syncs + if (syncIds.has(syncId)) + return + + syncIds.add(syncId) + if (enablePatches) { + const [newState, patches] = produceWithPatches( + state as unknown as Objectish, + fn as (draft: any) => void, + ) as unknown as [T, Patch[]] + state = newState + events.emit('updated', state, patches as unknown as SharedStatePatch[], syncId) + } + else { + state = produce(state as unknown as Objectish, fn as (draft: any) => void) as T + events.emit('updated', state, undefined, syncId) + } + }, + syncIds, + } +} diff --git a/packages/devframe/src/utils/streaming-channel.test.ts b/packages/devframe/src/utils/streaming-channel.test.ts new file mode 100644 index 0000000..2a736d9 --- /dev/null +++ b/packages/devframe/src/utils/streaming-channel.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest' +import { createStreamReader, createStreamSink } from './streaming-channel' + +describe('streaming-channel sink', () => { + it('emits chunk events with monotonic seq', () => { + const sink = createStreamSink() + const seen: Array<[number, string]> = [] + sink.events.on('chunk', (seq, chunk) => seen.push([seq, chunk])) + + sink.write('a') + sink.write('b') + sink.write('c') + + expect(seen).toEqual([[1, 'a'], [2, 'b'], [3, 'c']]) + expect(sink.lastSeq).toBe(3) + }) + + it('emits end exactly once on close() — second close is a no-op', () => { + const sink = createStreamSink() + const end = vi.fn() + sink.events.on('end', end) + + sink.close() + sink.close() + + expect(end).toHaveBeenCalledTimes(1) + expect(end).toHaveBeenCalledWith(undefined) + }) + + it('throws on write after close', () => { + const sink = createStreamSink() + sink.close() + expect(() => sink.write('x')).toThrow(/closed stream/) + }) + + it('emits end with error payload on error()', () => { + const sink = createStreamSink() + const end = vi.fn() + sink.events.on('end', end) + + sink.error(new TypeError('boom')) + + expect(end).toHaveBeenCalledWith({ name: 'TypeError', message: 'boom' }) + expect(sink.signal.aborted).toBe(true) + }) + + it('keeps a ring buffer up to replayWindow', () => { + const sink = createStreamSink({ replayWindow: 2 }) + sink.write('a') + sink.write('b') + sink.write('c') + + expect(sink.buffer.map(b => b.chunk)).toEqual(['b', 'c']) + expect(sink.buffer.map(b => b.seq)).toEqual([2, 3]) + }) + + it('keeps no buffer by default (replayWindow = 0)', () => { + const sink = createStreamSink() + sink.write('a') + sink.write('b') + expect(sink.buffer.length).toBe(0) + }) + + it('aborts signal on close so handlers can short-circuit', () => { + const sink = createStreamSink() + expect(sink.signal.aborted).toBe(false) + sink.close() + expect(sink.signal.aborted).toBe(true) + }) + + it('exposes a WritableStream that mirrors imperative writes', async () => { + const sink = createStreamSink() + const seen: Array<[number, string]> = [] + sink.events.on('chunk', (seq, chunk) => seen.push([seq, chunk])) + + const source = new ReadableStream({ + start(controller) { + controller.enqueue('x') + controller.enqueue('y') + controller.close() + }, + }) + await source.pipeTo(sink.writable) + + expect(seen).toEqual([[1, 'x'], [2, 'y']]) + expect(sink.closed).toBe(true) + }) + + it('errors the sink when writable abort fires', async () => { + const sink = createStreamSink() + const end = vi.fn() + sink.events.on('end', end) + + const source = new ReadableStream({ + start(controller) { + controller.error(new Error('upstream-failed')) + }, + }) + await source.pipeTo(sink.writable).catch(() => {}) + + expect(end).toHaveBeenCalledWith({ name: 'Error', message: 'upstream-failed' }) + }) +}) + +describe('streaming-channel reader', () => { + it('iterates chunks pushed by the RPC layer', async () => { + const reader = createStreamReader() + reader._push(1, 10) + reader._push(2, 20) + reader._end() + + const collected: number[] = [] + for await (const v of reader) collected.push(v) + + expect(collected).toEqual([10, 20]) + expect(reader.done).toBe(true) + expect(reader.lastSeenSeq).toBe(2) + }) + + it('blocks `for await` until next push', async () => { + const reader = createStreamReader() + const collected: number[] = [] + const consumer = (async () => { + for await (const v of reader) collected.push(v) + })() + + await Promise.resolve() + reader._push(1, 1) + await Promise.resolve() + reader._push(2, 2) + reader._end() + await consumer + + expect(collected).toEqual([1, 2]) + }) + + it('rejects the iterator with a real Error on _end with payload', async () => { + const reader = createStreamReader() + reader._end({ name: 'TypeError', message: 'boom' }) + + await expect((async () => { + for await (const _ of reader) { /* noop */ } + })()).rejects.toThrow(/boom/) + }) + + it('dedupes chunks with seq <= lastSeenSeq (replay)', async () => { + const reader = createStreamReader() + reader._push(1, 100) + reader._push(2, 200) + // Replayed + reader._push(1, 100) + reader._push(2, 200) + reader._push(3, 300) + reader._end() + + const collected: number[] = [] + for await (const v of reader) collected.push(v) + expect(collected).toEqual([100, 200, 300]) + }) + + it('drops oldest chunks on overflow and reports count', () => { + const onOverflow = vi.fn() + const reader = createStreamReader({ highWaterMark: 2, onOverflow }) + + reader._push(1, 1) + reader._push(2, 2) + reader._push(3, 3) + reader._push(4, 4) + + expect(onOverflow).toHaveBeenCalled() + // Queue should be capped at highWaterMark + expect(reader.lastSeenSeq).toBe(4) + }) + + it('cancel() invokes onCancel and ends the stream cleanly', async () => { + const onCancel = vi.fn() + const reader = createStreamReader({ onCancel }) + + const collected: number[] = [] + const consumer = (async () => { + for await (const v of reader) collected.push(v) + })() + + reader._push(1, 1) + await Promise.resolve() + reader.cancel() + await consumer + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(reader.cancelled).toBe(true) + expect(collected).toEqual([1]) + }) + + it('exposes a ReadableStream that surfaces the same chunks', async () => { + const reader = createStreamReader() + + const collected: string[] = [] + const piped = (async () => { + for await (const v of streamToAsyncIter(reader.readable)) + collected.push(v) + })() + + reader._push(1, 'x') + reader._push(2, 'y') + reader._end() + + await piped + expect(collected).toEqual(['x', 'y']) + }) + + it('cancelling the ReadableStream cancels the reader', async () => { + const onCancel = vi.fn() + const reader = createStreamReader({ onCancel }) + + const r = reader.readable.getReader() + await r.cancel('user-stop') + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(reader.cancelled).toBe(true) + }) +}) + +async function* streamToAsyncIter(stream: ReadableStream): AsyncIterable { + const reader = stream.getReader() + try { + while (true) { + const { value, done } = await reader.read() + if (done) + return + yield value + } + } + finally { + reader.releaseLock() + } +} diff --git a/packages/devframe/src/utils/streaming-channel.ts b/packages/devframe/src/utils/streaming-channel.ts new file mode 100644 index 0000000..9a8a9f2 --- /dev/null +++ b/packages/devframe/src/utils/streaming-channel.ts @@ -0,0 +1,390 @@ +import type { EventEmitter } from 'devframe/types' +import { createEventEmitter } from './events' +import { nanoid } from './nanoid' + +/** + * Serialized error shape sent over the wire when a stream ends with a failure. + * Stays JSON-safe so the strict-JSON encoder can carry it without coercion. + */ +export interface StreamErrorPayload { + name: string + message: string +} + +/** + * Single buffered chunk in the server-side ring buffer. + * + * Sequence numbers start at 1 and increment per write. Subscribers track + * `lastSeenSeq` and ask for `afterSeq` on resubscribe so the server can + * replay any chunks the client missed during a brief disconnect. + */ +export interface BufferedChunk { + seq: number + chunk: T +} + +export interface StreamSinkEvents { + /** Fired for each `write()`. The RPC layer subscribes and broadcasts. */ + chunk: (seq: number, chunk: T) => void + /** Terminal — fired exactly once per sink lifetime. */ + end: (error?: StreamErrorPayload) => void +} + +export interface CreateStreamSinkOptions { + id?: string + /** + * Size of the per-stream ring buffer kept for replay-on-resubscribe. + * `0` (default) disables replay. + */ + replayWindow?: number +} + +/** + * Server-side producer handle. Two equivalent surfaces share one piece of + * state: the imperative `write/error/close` triple, and a `WritableStream` + * for `pipeTo`-style consumption. + */ +export interface StreamSink { + /** Stable id used by clients to subscribe. */ + readonly id: string + /** + * Aborts when the consumer cancels (server-side) or when the transport + * loses every subscriber. Producers should poll `signal.aborted` and exit + * cleanly. + */ + readonly signal: AbortSignal + /** `true` after `close()` / `error()`. Further writes throw. */ + readonly closed: boolean + /** Last allocated sequence number. `0` until the first write. */ + readonly lastSeq: number + + write: (chunk: T) => void + error: (reason: unknown) => void + close: () => void + /** External-cancel path. Aborts the signal so handlers can short-circuit. */ + abort: (reason?: unknown) => void + + /** `WritableStream` adapter — same in-memory state as the imperative API. */ + readonly writable: WritableStream + + /** + * Internal — RPC layer subscribes to receive chunk/end notifications. + * Not part of the public contract; do not call directly. + * + * @internal + */ + readonly events: EventEmitter> + + /** + * Internal — replay buffer. RPC layer reads on (re)subscribe to feed + * missed chunks before going live. + * + * @internal + */ + readonly buffer: ReadonlyArray> +} + +export interface CreateStreamReaderOptions { + id?: string + /** + * Maximum number of buffered chunks held client-side while the consumer + * isn't draining. On overflow, the oldest chunk is dropped. + */ + highWaterMark?: number + /** + * Called when the chunk queue overflows the high-water mark. The RPC + * layer wires this to a coded warning; the primitive itself is + * RPC-agnostic. + */ + onOverflow?: (dropped: number) => void + /** Called when the consumer cancels — the RPC layer sends `:cancel` upstream. */ + onCancel?: () => void +} + +/** + * Client-side consumer handle. Both an `AsyncIterable` (for `for await`) + * and exposes `readable: ReadableStream` (for `pipeTo`). Pick one — they + * share a single internal queue, so concurrent draining will race. + */ +export interface StreamReader extends AsyncIterable { + readonly id: string + readonly cancelled: boolean + readonly done: boolean + /** Highest `seq` observed. Used for replay on reconnect. */ + readonly lastSeenSeq: number + /** `ReadableStream` adapter for `pipeTo`-style consumption. */ + readonly readable: ReadableStream + + cancel: () => void + + /** @internal */ + _push: (seq: number, chunk: T) => void + /** @internal */ + _end: (error?: StreamErrorPayload) => void +} + +const DEFAULT_HIGH_WATER_MARK = 256 + +class StreamClosedError extends Error { + override name = 'StreamClosedError' +} + +/** + * Build a server-side stream sink. RPC-agnostic — the RPC host wires + * `events.on('chunk' | 'end')` to broadcast, and reads `buffer` to replay + * for late or reconnecting subscribers. + */ +export function createStreamSink(options: CreateStreamSinkOptions = {}): StreamSink { + const id = options.id ?? nanoid() + const replayWindow = Math.max(0, options.replayWindow ?? 0) + const events = createEventEmitter>() + const controller = new AbortController() + const buffer: BufferedChunk[] = [] + + let closed = false + let lastSeq = 0 + + function write(chunk: T): void { + if (closed) { + throw new StreamClosedError(`Cannot write to a closed stream "${id}"`) + } + lastSeq += 1 + if (replayWindow > 0) { + buffer.push({ seq: lastSeq, chunk }) + if (buffer.length > replayWindow) + buffer.splice(0, buffer.length - replayWindow) + } + events.emit('chunk', lastSeq, chunk) + } + + function error(reason: unknown): void { + if (closed) + return + closed = true + const payload = toErrorPayload(reason) + controller.abort(reason) + events.emit('end', payload) + } + + function close(): void { + if (closed) + return + closed = true + if (!controller.signal.aborted) + controller.abort('stream closed') + events.emit('end', undefined) + } + + function abort(reason?: unknown): void { + if (closed) + return + if (!controller.signal.aborted) + controller.abort(reason ?? 'aborted') + } + + const writable = new WritableStream({ + write(chunk) { + write(chunk) + }, + close() { + close() + }, + abort(reason) { + error(reason) + }, + }) + + return { + id, + signal: controller.signal, + get closed() { return closed }, + get lastSeq() { return lastSeq }, + write, + error, + close, + abort, + writable, + events, + buffer, + } +} + +/** + * Build a client-side stream reader. RPC-agnostic — the RPC host calls + * `_push(seq, chunk)` on each incoming chunk and `_end(error?)` on the + * terminal frame. Consumers iterate with `for await` or pipe `readable`. + */ +export function createStreamReader(options: CreateStreamReaderOptions = {}): StreamReader { + const id = options.id ?? nanoid() + const highWaterMark = Math.max(1, options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK) + + const queue: T[] = [] + let lastSeenSeq = 0 + let done = false + let cancelled = false + let endError: StreamErrorPayload | undefined + let pending: { resolve: (r: IteratorResult) => void, reject: (err: unknown) => void } | undefined + // Lazily created — accessing `reader.readable` claims the queue for + // `pipeTo` consumption. While inactive, `_push` only feeds the + // AsyncIterator path. Mixing the two surfaces is undefined behavior. + let pullController: ReadableStreamDefaultController | undefined + let readableInstance: ReadableStream | undefined + + function drainNext(): void { + if (!pending) + return + if (queue.length > 0) { + const value = queue.shift()! + const r = pending + pending = undefined + r.resolve({ value, done: false }) + return + } + if (done) { + const r = pending + pending = undefined + if (endError) { + const err = new Error(endError.message) + err.name = endError.name + r.reject(err) + } + else { + r.resolve({ value: undefined as unknown as T, done: true }) + } + } + } + + function feedReadable(): void { + if (!pullController) + return + while (queue.length > 0) { + const v = queue.shift()! + try { + pullController.enqueue(v) + } + catch { + // controller closed/errored under us — drop silently; the + // ReadableStream consumer is gone and we can't push further. + break + } + } + if (done && pullController) { + try { + if (endError) { + const err = new Error(endError.message) + err.name = endError.name + pullController.error(err) + } + else { + pullController.close() + } + } + catch { + // already closed + } + pullController = undefined + } + } + + function push(seq: number, chunk: T): void { + if (done || cancelled) + return + if (seq <= lastSeenSeq) + return // dedupe replays we've already seen + lastSeenSeq = seq + queue.push(chunk) + if (queue.length > highWaterMark) { + const overflow = queue.length - highWaterMark + queue.splice(0, overflow) + options.onOverflow?.(overflow) + } + drainNext() + if (readableInstance) + feedReadable() + } + + function end(error?: StreamErrorPayload): void { + if (done) + return + done = true + endError = error + drainNext() + if (readableInstance) + feedReadable() + } + + function cancel(): void { + if (cancelled || done) + return + cancelled = true + options.onCancel?.() + end(undefined) + } + + function getReadable(): ReadableStream { + if (readableInstance) + return readableInstance + readableInstance = new ReadableStream({ + start(controller) { + pullController = controller + feedReadable() + }, + cancel() { + cancel() + }, + }) + return readableInstance + } + + const reader: StreamReader = { + id, + get cancelled() { return cancelled }, + get done() { return done }, + get lastSeenSeq() { return lastSeenSeq }, + get readable() { return getReadable() }, + cancel, + _push: push, + _end: end, + [Symbol.asyncIterator](): AsyncIterator { + return { + next(): Promise> { + if (queue.length > 0) { + return Promise.resolve({ value: queue.shift()!, done: false }) + } + if (done) { + if (endError) { + const err = new Error(endError.message) + err.name = endError.name + return Promise.reject(err) + } + return Promise.resolve({ value: undefined as unknown as T, done: true }) + } + return new Promise>((resolve, reject) => { + pending = { resolve, reject } + }) + }, + return(): Promise> { + cancel() + return Promise.resolve({ value: undefined as unknown as T, done: true }) + }, + } + }, + } + + return reader +} + +function toErrorPayload(reason: unknown): StreamErrorPayload { + if (reason instanceof Error) { + return { name: reason.name || 'Error', message: reason.message } + } + if (typeof reason === 'string') { + return { name: 'Error', message: reason } + } + try { + return { name: 'Error', message: JSON.stringify(reason) } + } + catch { + return { name: 'Error', message: String(reason) } + } +} diff --git a/packages/devframe/src/utils/structured-clone.ts b/packages/devframe/src/utils/structured-clone.ts new file mode 100644 index 0000000..0a7bf6e --- /dev/null +++ b/packages/devframe/src/utils/structured-clone.ts @@ -0,0 +1,36 @@ +import { + deserialize as deserializeImpl, + parse as parseImpl, + serialize as serializeImpl, + stringify as stringifyImpl, +} from 'structured-clone-es' + +/** + * Serialize a structured-cloneable value (`Map`, `Set`, `Date`, `BigInt`, + * cycles, class instances, …) into a JSON-safe records array. + */ +export function structuredCloneSerialize(value: unknown): unknown[] { + return serializeImpl(value) +} + +/** + * Inverse of {@link structuredCloneSerialize}. + */ +export function structuredCloneDeserialize(value: unknown[]): T { + return deserializeImpl(value) as T +} + +/** + * Serialize a structured-cloneable value to a single string. Equivalent + * to `JSON.stringify(structuredCloneSerialize(value))`. + */ +export function structuredCloneStringify(value: unknown): string { + return stringifyImpl(value) +} + +/** + * Inverse of {@link structuredCloneStringify}. + */ +export function structuredCloneParse(value: string): T { + return parseImpl(value) +} diff --git a/packages/devframe/src/utils/when.ts b/packages/devframe/src/utils/when.ts new file mode 100644 index 0000000..9c5fa36 --- /dev/null +++ b/packages/devframe/src/utils/when.ts @@ -0,0 +1,69 @@ +import type { WhenExpression as WhenExpressionImpl } from 'whenexpr' +import { evaluateWhen as evaluateWhenImpl, resolveContextValue as resolveContextValueImpl } from 'whenexpr' + +/** + * Context object for evaluating `when` expressions. + * + * Built-in variables: + * - `clientType` — `'embedded' | 'standalone'` + * - `dockOpen` — whether the dock panel is open + * - `paletteOpen` — whether the command palette is open + * - `dockSelectedId` — ID of the selected dock entry (empty string if none) + * + * Plugins can add namespaced variables using dot or colon separators: + * - `vite.mode`, `vite:mode` — stored as `{ 'vite.mode': 'development' }` or nested `{ vite: { mode: 'development' } }` + * + * Supported operators: `!`, `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=`, + * `&&`, `||`, `+`, `-`, `*`, `/`, `%`. Parentheses, number and string literals + * are also supported. + */ +export interface WhenContext { + clientType: 'embedded' | 'standalone' + dockOpen: boolean + paletteOpen: boolean + dockSelectedId: string + /** Allow custom context variables from plugins */ + [key: string]: unknown +} + +/** + * A statically-validated `when` expression string. Used inside `define*` + * helpers to surface unknown context keys and syntax errors as TypeScript + * errors at the call site. + */ +export type WhenExpression = WhenExpressionImpl + +/** Options for `evaluateWhen`. */ +export interface EvaluateWhenOptions { + /** + * Throw when the expression references a context key that does not exist. + * + * @default false + */ + strict?: boolean +} + +/** + * Evaluate a when-clause expression string against a context object. + * + * @example + * evaluateWhen('dockOpen && clientType == embedded', ctx) + */ +export function evaluateWhen( + expression: E & WhenExpressionImpl, + context: T, + options?: EvaluateWhenOptions, +): boolean { + return evaluateWhenImpl(expression as any, context, options) +} + +/** + * Resolve a context value by key. Supports namespaced keys with `.` or `:` + * separators. Returns `undefined` for unknown keys. + */ +export function resolveContextValue>( + key: string, + context: T, +): unknown { + return resolveContextValueImpl(key, context) +} diff --git a/packages/devframe/tsconfig.json b/packages/devframe/tsconfig.json new file mode 100644 index 0000000..8a6d5a7 --- /dev/null +++ b/packages/devframe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["esnext", "dom"] + } +} diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts new file mode 100644 index 0000000..b45aa86 --- /dev/null +++ b/packages/devframe/tsdown.config.ts @@ -0,0 +1,91 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + 'index': 'src/index.ts', + 'rpc/index': 'src/rpc/index.ts', + 'rpc/client': 'src/rpc/client.ts', + 'rpc/server': 'src/rpc/server.ts', + 'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts', + 'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts', + 'types/index': 'src/types/index.ts', + 'node/index': 'src/node/index.ts', + 'node/auth': 'src/node/auth/index.ts', + 'node/internal': 'src/node/internal/index.ts', + 'constants': 'src/constants.ts', + 'utils/colors': 'src/utils/colors.ts', + 'utils/events': 'src/utils/events.ts', + 'utils/hash': 'src/utils/hash.ts', + 'utils/human-id': 'src/utils/human-id.ts', + 'utils/launch-editor': 'src/utils/launch-editor.ts', + 'utils/nanoid': 'src/utils/nanoid.ts', + 'utils/open': 'src/utils/open.ts', + 'utils/promise': 'src/utils/promise.ts', + 'utils/serve-static': 'src/utils/serve-static.ts', + 'utils/shared-state': 'src/utils/shared-state.ts', + 'utils/streaming-channel': 'src/utils/streaming-channel.ts', + 'utils/structured-clone': 'src/utils/structured-clone.ts', + 'utils/when': 'src/utils/when.ts', + 'adapters/cli': 'src/adapters/cli.ts', + 'adapters/dev': 'src/adapters/dev.ts', + 'adapters/build': 'src/adapters/build.ts', + 'adapters/vite': 'src/adapters/vite.ts', + 'adapters/embedded': 'src/adapters/embedded.ts', + 'adapters/mcp': 'src/adapters/mcp.ts', + 'client/index': 'src/client/index.ts', + 'recipes/open-helpers': 'src/recipes/open-helpers.ts', + }, + tsconfig: '../../tsconfig.base.json', + clean: true, + dts: true, + exports: true, + // Keep transitive external type graphs out of dts bundling. + // `vite`/`esbuild`/`postcss` are pulled in via the kit client's + // `declare module 'vite'` augmentation and contain + // rolldown-incompatible re-exports that would otherwise fail dts + // generation with dozens of MISSING_EXPORT errors. + deps: { + neverBundle: [ + 'vite', + 'esbuild', + 'postcss', + 'rolldown', + /^@rolldown\//, + /^@oxc-project\//, + 'terser', + '@jridgewell/trace-mapping', + ], + onlyBundle: [ + 'acorn', + 'ansis', + 'bundle-name', + 'default-browser', + 'default-browser-id', + 'define-lazy-prop', + 'get-port-please', + 'human-id', + 'immer', + 'is-docker', + 'is-in-ssh', + 'is-inside-container', + 'is-wsl', + 'launch-editor', + 'mlly', + 'obug', + 'ohash', + 'open', + 'p-limit', + 'perfect-debounce', + 'picocolors', + 'powershell-utils', + 'run-applescript', + 'shell-quote', + 'structured-clone-es', + 'tinyexec', + 'ua-parser-modern', + 'whenexpr', + 'wsl-utils', + 'yocto-queue', + ], + }, +}) diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json new file mode 100644 index 0000000..df16e81 --- /dev/null +++ b/packages/nuxt/package.json @@ -0,0 +1,42 @@ +{ + "name": "@devframes/nuxt", + "type": "module", + "version": "0.1.22", + "description": "Nuxt module for Devframe — wires a Nuxt-built SPA up as a devframe client", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "packages/nuxt", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "nuxt", + "devtools" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./runtime/plugin.client": "./dist/runtime/plugin.client.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch" + }, + "peerDependencies": { + "@nuxt/kit": "^3.0.0 || ^4.0.0", + "devframe": "workspace:*" + }, + "devDependencies": { + "@nuxt/kit": "catalog:build", + "tsdown": "catalog:build" + } +} diff --git a/packages/nuxt/src/index.ts b/packages/nuxt/src/index.ts new file mode 100644 index 0000000..725de94 --- /dev/null +++ b/packages/nuxt/src/index.ts @@ -0,0 +1,142 @@ +import type { DevframeDefinition } from 'devframe/types' +import { addPlugin, addVitePlugin, createResolver, defineNuxtModule } from '@nuxt/kit' +import { createVitePlugin } from 'devframe/adapters/vite' + +export interface DevframeNuxtModuleOptions { + /** + * Base URL, relative to the deployed page, where the devframe + * connection meta (`__connection.json`) and dump shards live. + * Defaults to `'./'` — the SPA root — so a single build works at any + * deployment base (the browser resolves relative fetches against + * `document.baseURI`). + */ + baseURL?: string + /** + * Disable the opinionated Nuxt app defaults (`app.baseURL: './'`, + * `vite.base: './'`). Set to `true` if you need to own these yourself. + * Defaults to `false` — devframe sets sensible base-agnostic defaults. + */ + skipAppDefaults?: boolean + /** + * Devframe definition that powers the dev-time RPC bridge. When set + * (and Nuxt is in dev mode), the module starts a separate RPC + WS + * server alongside `nuxt dev` and registers Vite middleware at + * `${baseURL}__connection.json` so the client SPA can discover the + * WS endpoint. Without it the module stays client-only. + */ + devframe?: DevframeDefinition + /** + * Dev-time middleware mode. Mirrors `createVitePlugin`'s option of the + * same name. + * + * - `true` (default) — when `devframe` is set and Nuxt is in dev + * mode, start the RPC bridge with all defaults. + * - `false` — skip the bridge entirely. The module remains + * client-only. + * - object — enable with explicit overrides. + */ + devMiddleware?: boolean | { + /** Override the bridge port. Default: resolved via `get-port-please`. */ + port?: number + /** + * Override the bridge bind host. Defaults to + * `nuxt.options.devServer.host ?? devframe.cli?.host ?? 'localhost'`, + * so `nuxt dev --host` propagates automatically. + */ + host?: string + /** Flag bag forwarded to `devframe.setup(ctx, { flags })`. */ + flags?: Record + } +} + +/** + * Nuxt module that wires a Nuxt-built SPA up as a devframe client, and + * (optionally) serves the dev-time RPC bridge alongside `nuxt dev`. + * + * - Sets `app.baseURL: './'` + `vite.base: './'` so the production + * build is base-agnostic (works at any deployment path without + * build-time rewriting). + * - Injects a client plugin that calls {@link connectDevframe} once on + * page load and exposes the RPC client via `useNuxtApp().$rpc`. + * - When `devframe` is provided and Nuxt is in dev mode, registers a + * Vite plugin (via `addVitePlugin(createVitePlugin(devframe, { + * devMiddleware: ... }))`) that starts the RPC + WS bridge and + * serves `${baseURL}__connection.json`. + * + * ```ts [nuxt.config.ts] + * import devframe from './src/devframe' // defineDevframe(...) export + * + * export default defineNuxtConfig({ + * modules: [['@devframes/nuxt', { devframe }]], + * }) + * ``` + * + * At the call site: + * + * ```ts [composables/payload.ts] + * export async function fetchPayload() { + * const { $rpc } = useNuxtApp() + * return $rpc.call('my-tool:get-payload') + * } + * ``` + */ +export default defineNuxtModule({ + meta: { + name: 'devframe', + configKey: 'devframe', + }, + defaults: { + baseURL: './', + skipAppDefaults: false, + devMiddleware: true, + }, + setup(options, nuxt) { + const { resolve } = createResolver(import.meta.url) + + if (!options.skipAppDefaults) { + // Relative app baseURL so the production SSG output resolves + // assets against `document.baseURI`. Leaves explicit overrides + // alone — authors who set these already are in charge. + nuxt.options.app ??= {} as any + nuxt.options.app.baseURL ??= './' + nuxt.options.vite ??= {} + ;(nuxt.options.vite as any).base ??= './' + } + + // Expose the resolved baseURL to the runtime plugin via Nuxt's + // `runtimeConfig.public` so it survives a Nitro static build. + nuxt.options.runtimeConfig ??= {} as any + nuxt.options.runtimeConfig.public ??= {} as any + const publicConfig = nuxt.options.runtimeConfig.public as Record + publicConfig.devframe = { + ...(publicConfig.devframe ?? {}), + baseURL: options.baseURL, + } + + addPlugin({ + src: resolve('./runtime/plugin.client'), + mode: 'client', + }) + + // Dev-time RPC bridge. Skipped without a definition or when the + // user opts out; `apply: 'serve'` on the inner Vite plugin is a + // second guard against accidental activation during build. + if (options.devframe && options.devMiddleware !== false && nuxt.options.dev) { + const mw = options.devMiddleware === true || options.devMiddleware === undefined + ? {} + : options.devMiddleware + const host = mw.host + ?? (nuxt.options.devServer as any)?.host + ?? options.devframe.cli?.host + + addVitePlugin(createVitePlugin(options.devframe, { + base: options.baseURL ?? './', + devMiddleware: { + port: mw.port, + host, + flags: mw.flags, + }, + }) as any) + } + }, +}) diff --git a/packages/nuxt/src/runtime/plugin.client.ts b/packages/nuxt/src/runtime/plugin.client.ts new file mode 100644 index 0000000..8873909 --- /dev/null +++ b/packages/nuxt/src/runtime/plugin.client.ts @@ -0,0 +1,19 @@ +import type { DevToolsRpcClient } from 'devframe/client' +import { connectDevframe } from 'devframe/client' +// `#app` is a Nuxt virtual module; types resolve via `@nuxt/schema`. +import { defineNuxtPlugin, useRuntimeConfig } from '#app' + +/** + * Nuxt client plugin that calls `connectDevframe()` once on the client + * and provides the RPC client as `$rpc` / `useNuxtApp().$rpc`. + */ +export default defineNuxtPlugin(async () => { + const config = useRuntimeConfig() + const baseURL = (config.public as any)?.devframe?.baseURL ?? './' + const rpc = await connectDevframe({ baseURL }) + return { + provide: { + rpc: rpc as DevToolsRpcClient, + }, + } +}) diff --git a/packages/nuxt/src/runtime/types.d.ts b/packages/nuxt/src/runtime/types.d.ts new file mode 100644 index 0000000..40a5898 --- /dev/null +++ b/packages/nuxt/src/runtime/types.d.ts @@ -0,0 +1,20 @@ +import type { DevToolsRpcClient } from 'devframe/client' + +declare module '#app' { + interface NuxtApp { + /** + * Devframe RPC client, provided by the `@devframes/nuxt` module's + * client plugin. + */ + $rpc: DevToolsRpcClient + } +} + +declare module 'vue' { + interface ComponentCustomProperties { + /** Devframe RPC client (see `NuxtApp['$rpc']`). */ + $rpc: DevToolsRpcClient + } +} + +export {} diff --git a/packages/nuxt/tsconfig.json b/packages/nuxt/tsconfig.json new file mode 100644 index 0000000..8a6d5a7 --- /dev/null +++ b/packages/nuxt/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["esnext", "dom"] + } +} diff --git a/packages/nuxt/tsdown.config.ts b/packages/nuxt/tsdown.config.ts new file mode 100644 index 0000000..d24a7af --- /dev/null +++ b/packages/nuxt/tsdown.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + 'index': 'src/index.ts', + 'runtime/plugin.client': 'src/runtime/plugin.client.ts', + }, + tsconfig: '../../tsconfig.base.json', + clean: true, + dts: true, + exports: true, + // Keep transitive Nuxt/Vite type graphs out of dts bundling. Consumers + // resolve these via their own node_modules at install time. + deps: { + neverBundle: [ + '@nuxt/kit', + '@nuxt/schema', + '@vitejs/plugin-vue-jsx', + '@vue/babel-plugin-jsx', + '@vue/babel-plugin-resolve-type', + 'scule', + ], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1729d0e..53dcaad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,48 +5,142 @@ settings: excludeLinksFromLockfile: false catalogs: - cli: - '@antfu/eslint-config': - specifier: ^8.2.0 - version: 8.2.0 + build: '@antfu/ni': specifier: ^30.1.0 version: 30.1.0 + '@nuxt/kit': + specifier: ^4.4.5 + version: 4.4.5 + '@preact/preset-vite': + specifier: ^2.10.5 + version: 2.10.5 + tsdown: + specifier: ^0.22.0 + version: 0.22.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + turbo: + specifier: ^2.9.12 + version: 2.9.12 + vite: + specifier: ^8.0.11 + version: 8.0.11 + deps: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0 + '@valibot/to-json-schema': + specifier: ^1.7.0 + version: 1.7.0 + ansis: + specifier: ^4.2.0 + version: 4.2.0 + birpc: + specifier: ^4.0.0 + version: 4.0.0 + cac: + specifier: ^7.0.0 + version: 7.0.0 + get-port-please: + specifier: ^3.2.0 + version: 3.2.0 + h3: + specifier: ^1.15.11 + version: 1.15.11 + immer: + specifier: ^11.1.8 + version: 11.1.8 + launch-editor: + specifier: ^2.13.2 + version: 2.13.2 + logs-sdk: + specifier: ^0.0.6 + version: 0.0.6 + mrmime: + specifier: ^2.0.1 + version: 2.0.1 + obug: + specifier: ^2.1.1 + version: 2.1.1 + ohash: + specifier: ^2.0.11 + version: 2.0.11 + open: + specifier: ^11.0.0 + version: 11.0.0 + p-limit: + specifier: ^7.3.0 + version: 7.3.0 + pathe: + specifier: ^2.0.3 + version: 2.0.3 + perfect-debounce: + specifier: ^2.1.0 + version: 2.1.0 + structured-clone-es: + specifier: ^2.0.0 + version: 2.0.0 + tinyglobby: + specifier: ^0.2.16 + version: 0.2.16 + valibot: + specifier: ^1.4.0 + version: 1.4.0 + whenexpr: + specifier: ^0.1.2 + version: 0.1.2 + ws: + specifier: ^8.20.0 + version: 8.20.0 + devtools: + '@antfu/eslint-config': + specifier: ^8.2.0 + version: 8.2.0 bumpp: - specifier: ^11.0.1 - version: 11.0.1 + specifier: ^11.1.0 + version: 11.1.0 eslint: specifier: ^10.3.0 version: 10.3.0 - lint-staged: - specifier: ^17.0.2 - version: 17.0.2 - publint: - specifier: ^0.3.19 - version: 0.3.19 + nano-staged: + specifier: ^1.0.2 + version: 1.0.2 simple-git-hooks: specifier: ^2.13.1 version: 2.13.1 - tsdown: - specifier: ^0.21.10 - version: 0.21.10 - tsx: - specifier: ^4.21.0 - version: 4.21.0 + skills-npm: + specifier: ^1.1.1 + version: 1.1.1 typescript: specifier: ^6.0.3 version: 6.0.3 - vite: - specifier: ^8.0.11 - version: 8.0.11 + docs: + mermaid: + specifier: ^11.14.0 + version: 11.14.0 + vitepress: + specifier: ^2.0.0-alpha.17 + version: 2.0.0-alpha.17 + vitepress-plugin-mermaid: + specifier: ^2.0.17 + version: 2.0.17 + frontend: + preact: + specifier: ^10.29.1 + version: 10.29.1 inlined: '@antfu/utils': specifier: ^9.3.0 version: 9.3.0 - testing: - tsdown-stale-guard: + human-id: + specifier: ^4.1.3 + version: 4.1.3 + ua-parser-modern: specifier: ^0.1.1 version: 0.1.1 + testing: tsnapi: specifier: ^0.3.2 version: 0.3.2 @@ -56,60 +150,241 @@ catalogs: types: '@types/node': specifier: ^25.6.0 - version: 25.6.0 + version: 25.6.2 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + +overrides: + semver: ^7.8.0 importers: .: devDependencies: '@antfu/eslint-config': - specifier: catalog:cli - version: 8.2.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3))(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))) + specifier: catalog:devtools + version: 8.2.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3))(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))) '@antfu/ni': - specifier: catalog:cli + specifier: catalog:build version: 30.1.0 '@antfu/utils': specifier: catalog:inlined version: 9.3.0 '@types/node': specifier: catalog:types - version: 25.6.0 + version: 25.6.2 + '@types/ws': + specifier: catalog:types + version: 8.18.1 bumpp: - specifier: catalog:cli - version: 11.0.1 + specifier: catalog:devtools + version: 11.1.0 eslint: - specifier: catalog:cli + specifier: catalog:devtools version: 10.3.0(jiti@2.7.0) - lint-staged: - specifier: catalog:cli - version: 17.0.2 - publint: - specifier: catalog:cli - version: 0.3.19 + nano-staged: + specifier: catalog:devtools + version: 1.0.2 simple-git-hooks: - specifier: catalog:cli + specifier: catalog:devtools version: 2.13.1 + skills-npm: + specifier: catalog:devtools + version: 1.1.1 tsdown: - specifier: catalog:cli - version: 0.21.10(publint@0.3.19)(synckit@0.11.12)(typescript@6.0.3) - tsdown-stale-guard: - specifier: catalog:testing - version: 0.1.1(tsdown@0.21.10(publint@0.3.19)(synckit@0.11.12)(typescript@6.0.3)) + specifier: catalog:build + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3) tsnapi: specifier: catalog:testing - version: 0.3.2(vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))) + version: 0.3.2(vitest@4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))) tsx: - specifier: catalog:cli + specifier: catalog:build version: 4.21.0 + turbo: + specifier: catalog:build + version: 2.9.12 typescript: - specifier: catalog:cli + specifier: catalog:devtools version: 6.0.3 vite: - specifier: catalog:cli - version: 8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + specifier: catalog:build + version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: + specifier: catalog:testing + version: 4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + + docs: + devDependencies: + devframe: + specifier: workspace:* + version: link:../packages/devframe + mermaid: + specifier: catalog:docs + version: 11.14.0 + tinyglobby: + specifier: catalog:deps + version: 0.2.16 + vitepress: + specifier: catalog:docs + version: 2.0.0-alpha.17(@types/node@25.6.2)(change-case@5.4.4)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.4) + vitepress-plugin-mermaid: + specifier: catalog:docs + version: 2.0.17(mermaid@11.14.0)(vitepress@2.0.0-alpha.17(@types/node@25.6.2)(change-case@5.4.4)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.4)) + + examples/devframe-counter: + dependencies: + devframe: + specifier: workspace:* + version: link:../../packages/devframe + + examples/devframe-files-inspector: + dependencies: + devframe: + specifier: workspace:* + version: link:../../packages/devframe + preact: + specifier: catalog:frontend + version: 10.29.1 + tinyglobby: + specifier: catalog:deps + version: 0.2.16 + devDependencies: + '@preact/preset-vite': + specifier: catalog:build + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.3)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + h3: + specifier: catalog:deps + version: 1.15.11 + vite: + specifier: catalog:build + version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) vitest: specifier: catalog:testing - version: 4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + ws: + specifier: catalog:deps + version: 8.20.0 + + examples/devframe-streaming-chat: + dependencies: + devframe: + specifier: workspace:* + version: link:../../packages/devframe + preact: + specifier: catalog:frontend + version: 10.29.1 + devDependencies: + '@preact/preset-vite': + specifier: catalog:build + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.3)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + h3: + specifier: catalog:deps + version: 1.15.11 + vite: + specifier: catalog:build + version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: + specifier: catalog:testing + version: 4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + ws: + specifier: catalog:deps + version: 8.20.0 + + packages/devframe: + dependencies: + '@valibot/to-json-schema': + specifier: catalog:deps + version: 1.7.0(valibot@1.4.0(typescript@6.0.3)) + birpc: + specifier: catalog:deps + version: 4.0.0 + cac: + specifier: catalog:deps + version: 7.0.0 + h3: + specifier: catalog:deps + version: 1.15.11 + logs-sdk: + specifier: catalog:deps + version: 0.0.6 + mrmime: + specifier: catalog:deps + version: 2.0.1 + pathe: + specifier: catalog:deps + version: 2.0.3 + valibot: + specifier: catalog:deps + version: 1.4.0(typescript@6.0.3) + ws: + specifier: catalog:deps + version: 8.20.0 + devDependencies: + '@modelcontextprotocol/sdk': + specifier: catalog:deps + version: 1.29.0(zod@4.4.3) + ansis: + specifier: catalog:deps + version: 4.2.0 + get-port-please: + specifier: catalog:deps + version: 3.2.0 + human-id: + specifier: catalog:inlined + version: 4.1.3 + immer: + specifier: catalog:deps + version: 11.1.8 + launch-editor: + specifier: catalog:deps + version: 2.13.2 + obug: + specifier: catalog:deps + version: 2.1.1 + ohash: + specifier: catalog:deps + version: 2.0.11 + open: + specifier: catalog:deps + version: 11.0.0 + p-limit: + specifier: catalog:deps + version: 7.3.0 + perfect-debounce: + specifier: catalog:deps + version: 2.1.0 + structured-clone-es: + specifier: catalog:deps + version: 2.0.0 + tsdown: + specifier: catalog:build + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3) + ua-parser-modern: + specifier: catalog:inlined + version: 0.1.1 + whenexpr: + specifier: catalog:deps + version: 0.1.2 + + packages/nuxt: + dependencies: + devframe: + specifier: workspace:* + version: link:../devframe + devDependencies: + '@nuxt/kit': + specifier: catalog:build + version: 4.4.5 + tsdown: + specifier: catalog:build + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3) packages: @@ -185,10 +460,52 @@ packages: '@antfu/utils@9.3.0': resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} - '@babel/generator@8.0.0-rc.3': - resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@8.0.0-rc.4': + resolution: {integrity: sha512-YZ+FuIgkj7KrIb2a2X1XiY0QYgDxAbVbYP64SjwJzOK3euCsUerzenh2oqdsmKuPSlhzmFOOklnxzHAzXagvpw==} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -201,28 +518,83 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.0-rc.3': - resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==} + '@babel/helper-validator-identifier@8.0.0-rc.4': + resolution: {integrity: sha512-HTD3bskipk5MSm08twTW6832jzIXUhxMddy4NPPzIMuyMEsrs0ZgwAaMj5ubB5+6hMlUjDu17vNconEmwsmpYg==} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.0-rc.3': - resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==} + '@babel/parser@8.0.0-rc.4': + resolution: {integrity: sha512-0S/1yefMa15N4i2v3t8Fw9pgMHhf2gF6Lc1UEXI96Ls6FNAjqvHHZouZ2ZS/deqLhbMFtmfVeFac6iTsvFbLwA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.0-rc.3': - resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==} + '@babel/types@8.0.0-rc.4': + resolution: {integrity: sha512-bw30DV880P/VYtsjWWdoWmJpb9S2Vn1/PqayyccTELzRQ/HslIO7+BD9rNoZ4AAFOAjC1vrNeBCkAsyh6Ibfww==} engines: {node: ^20.19.0 || >=22.12.0} + '@braintree/sanitize-url@6.0.4': + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/cst-dts-gen@12.0.0': + resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} + + '@chevrotain/gast@12.0.0': + resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} + + '@chevrotain/regexp-to-ast@12.0.0': + resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} + + '@chevrotain/types@12.0.0': + resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} + + '@chevrotain/utils@12.0.0': + resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + '@clack/core@1.3.0': resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} engines: {node: '>= 20.12.0'} @@ -231,6 +603,15 @@ packages: resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} engines: {node: '>= 20.12.0'} + '@docsearch/css@4.6.3': + resolution: {integrity: sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==} + + '@docsearch/js@4.6.3': + resolution: {integrity: sha512-qUIX2b4Apew3tv4F0qhmgShsl/Lfw4m6mqv/5/5dWNxwTcDdLMp2s3YwZ+NMGh3IKCg0pBaXm7Q5VdyU5Rj+cQ==} + + '@docsearch/sidepanel-js@4.6.3': + resolution: {integrity: sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==} + '@e18e/eslint-plugin@0.3.0': resolution: {integrity: sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg==} peerDependencies: @@ -441,8 +822,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@2.0.5': - resolution: {integrity: sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg==} + '@eslint/compat@2.1.0': + resolution: {integrity: sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: eslint: ^8.40 || 9 || 10 @@ -478,6 +859,12 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -498,6 +885,15 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify-json/simple-icons@1.2.81': + resolution: {integrity: sha512-Utjw4sPtoVdbpAQAkC4O0cYpt4ehQZYr6aFHhmvdeW8mQwkINyAe0ogTPqNptSSKogZ2lfgXM8zpuhO961Wnng==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -514,12 +910,32 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/mermaid-mindmap@9.3.0': + resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} + + '@mermaid-js/parser@1.1.0': + resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nuxt/kit@4.4.5': + resolution: {integrity: sha512-J0BpoOomzd3iVZozYlZJ7AwAVliXRgeChZnAkQLfg8d0h/Q+aMK9kkHuhwFULASaRn5idiD4BIhOUz7/uoLbSw==} + engines: {node: '>=18.12.0'} + '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -654,25 +1070,44 @@ packages: '@oxc-project/types@0.126.0': resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} - '@oxc-project/types@0.127.0': - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.128.0': resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + '@oxc-project/types@0.129.0': + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@publint/pack@0.1.4': - resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} - engines: {node: '>=18'} + '@preact/preset-vite@2.10.5': + resolution: {integrity: sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==} + peerDependencies: + '@babel/core': 7.x + vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x + + '@prefresh/babel-plugin@0.5.3': + resolution: {integrity: sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==} + + '@prefresh/core@1.5.9': + resolution: {integrity: sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==} + peerDependencies: + preact: ^10.0.0 || ^11.0.0-0 + + '@prefresh/utils@1.2.1': + resolution: {integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==} + + '@prefresh/vite@2.4.12': + resolution: {integrity: sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==} + peerDependencies: + preact: ^10.4.0 || ^11.0.0-0 + vite: '>=2.0.0' '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@rolldown/binding-android-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + '@rolldown/binding-android-arm64@1.0.0': + resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -683,8 +1118,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + '@rolldown/binding-darwin-arm64@1.0.0': + resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -695,8 +1130,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + '@rolldown/binding-darwin-x64@1.0.0': + resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -707,8 +1142,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + '@rolldown/binding-freebsd-x64@1.0.0': + resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -719,8 +1154,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -731,8 +1166,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + '@rolldown/binding-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -745,8 +1180,8 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + '@rolldown/binding-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -759,8 +1194,8 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -773,8 +1208,8 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + '@rolldown/binding-linux-s390x-gnu@1.0.0': + resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -787,8 +1222,8 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + '@rolldown/binding-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -801,8 +1236,8 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + '@rolldown/binding-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -815,8 +1250,8 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + '@rolldown/binding-openharmony-arm64@1.0.0': + resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -827,8 +1262,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + '@rolldown/binding-wasm32-wasi@1.0.0': + resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] @@ -837,8 +1272,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -849,8 +1284,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + '@rolldown/binding-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -861,12 +1296,190 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.17': - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@rolldown/pluginutils@1.0.0': + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} + + '@rolldown/pluginutils@1.0.0-rc.13': + resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} '@rolldown/pluginutils@1.0.0-rc.18': resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + '@rollup/pluginutils@4.2.1': + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@3.23.0': + resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/base62@1.0.0': resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} @@ -880,48 +1493,195 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@turbo/darwin-64@2.9.12': + resolution: {integrity: sha512-eu3eFRmE9NjgZ0wPdRJ44l+LGSeIky+tz5ZQd8zQkw/Yqi+BM7wq+8nbabeoiVUcICi/IZweMOKl/MCmkrd1+g==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.12': + resolution: {integrity: sha512-RUkAE404z/J8NsyrUosMcBaXT6M4bRFxTQrmkDQBLQVXaC8Jl0e9bMvYDSX0GW7Ffm2m3j9y7RXgR1foeUAM9w==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.12': + resolution: {integrity: sha512-InIUtH7cw/vqXNX1Gr7QgWfmw3ct08pV5CpfdEOR48z2u2rzdmpIuk00B/Q2xCb0PMWtKgiMQynfuphmEuUyTQ==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.12': + resolution: {integrity: sha512-lC6nD//Xh67fmJM0LKaLsg74Wry0aYrgMklpiNgCbUaMdPIOqj0A00iri3NU7Lb7pZHx8ViisgpeDKlpSgFUCA==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.12': + resolution: {integrity: sha512-conYri8VUl72JOdYnLDPYwzqbPcY5ECoHmo9FWoKznemhaAIilj4maHqs9Uar0aKfNoZIULniy+6iWaLtLO34A==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.12': + resolution: {integrity: sha512-XoR4bsg62/L/esRVcmoMESEiNZ36+YmyjYGLpoqk8nwMgXzzVjNOgX0lRSz5w/U/ajLGv3nhMsS0Q2QOdvp2AQ==} + cpu: [arm64] + os: [win32] + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - '@types/jsesc@2.5.1': - resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} - '@types/katex@0.16.8': - resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.59.2': resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -988,8 +1748,26 @@ packages: resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/eslint-plugin@1.6.16': - resolution: {integrity: sha512-2pBN1F1JXq6zTSaYC58CMJa7pGxXIRsLfOioeZM4cPE3pRdSh1ySTSoHPQlOTEF5WgoVzWZQxhGQ3ygT78hOVg==} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@valibot/to-json-schema@1.7.0': + resolution: {integrity: sha512-Y3pPVibbIOHzohrlxSINvO7w/bvXkoYS3BQHoImV9ynE+bXKf171bdMucPurV2zp7gdmt0L1HCcNAsbo7cFRQw==} + peerDependencies: + valibot: ^1.4.0 + + '@vitejs/plugin-vue@6.0.6': + resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/eslint-plugin@1.6.17': + resolution: {integrity: sha512-sIVY9ZeVcXyPxFCNRkIt8Yw4keKIcUyp9/8qnmuomPwE+ST1htw5sZsbqdUMTiah9SmCg1JYoK9RqdDtPeNYYg==} engines: {node: '>=18'} peerDependencies: '@typescript-eslint/eslint-plugin': '*' @@ -1045,9 +1823,91 @@ packages: '@vue/compiler-ssr@3.5.34': resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + '@vue/devtools-api@8.1.2': + resolution: {integrity: sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==} + + '@vue/devtools-kit@8.1.2': + resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==} + + '@vue/devtools-shared@8.1.2': + resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==} + + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + peerDependencies: + vue: 3.5.34 + '@vue/shared@3.5.34': resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/integrations@14.3.0': + resolution: {integrity: sha512-76I5FT2ESvCmCaSwapI+a/u/CFtNXmzl9f9lNp1hRtx8vKB8hfiokJr8IvQqcQG5ckGXElyXK516b54ozV3MvA==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 || ^8 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 || ^8 + vue: ^3.5.0 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1058,20 +1918,19 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} @@ -1081,6 +1940,9 @@ packages: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + args-tokenizer@0.3.0: resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} @@ -1092,23 +1954,35 @@ packages: resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} engines: {node: '>=20.19.0'} + babel-plugin-transform-hook-names@1.0.2: + resolution: {integrity: sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==} + peerDependencies: + '@babel/core': ^7.12.10 + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.27: - resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} hasBin: true + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} browserslist@4.28.2: @@ -1116,19 +1990,43 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - builtin-modules@5.1.0: - resolution: {integrity: sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==} + builtin-modules@5.2.0: + resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==} engines: {node: '>=18.20'} - bumpp@11.0.1: - resolution: {integrity: sha512-X0ti27I/ewsx/u0EJSyl0IZWWOE95q+wIpAG/60kc5gqMNR4a23YJdd3lL7JsBN11TgLbCM4KpfGMuFfdigb4g==} + bumpp@11.1.0: + resolution: {integrity: sha512-jdwOGMyX8JIqpQ0N2RMRR87DHZaoJnUtui5lU9LqFfFK5JC0H8qY9uWqXoa+dEWt/K7rOmmsoyiZB8RBM7RPBQ==} engines: {node: '>=20.19.0'} hasBin: true + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} @@ -1142,24 +2040,45 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chevrotain-allstar@0.4.3: + resolution: {integrity: sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==} + peerDependencies: + chevrotain: ^12.0.0 + + chevrotain@12.0.0: + resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} + engines: {node: '>=22.0.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + clean-regexp@1.0.0: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} @@ -1179,21 +2098,226 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.3: + resolution: {integrity: sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1209,13 +2333,35 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1227,40 +2373,83 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dts-resolver@2.1.3: - resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} - engines: {node: '>=20.19.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} peerDependencies: oxc-resolver: '>=11.0.0' peerDependenciesMeta: oxc-resolver: optional: true - electron-to-chromium@1.5.352: - resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + electron-to-chromium@1.5.353: + resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} - empathic@2.0.0: - resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} - enhanced-resolve@5.21.0: - resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.21.2: + resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} + errx@0.1.0: + resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1270,6 +2459,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -1312,8 +2504,8 @@ packages: peerDependencies: eslint: '*' - eslint-plugin-antfu@3.2.2: - resolution: {integrity: sha512-Qzixht2Dmd/pMbb5EnKqw2V8TiWHbotPlsORO8a+IzCLFwE0RxK8a9k4DCTFPzBwyxJzH+0m2Mn8IUGeGQkyUw==} + eslint-plugin-antfu@3.2.3: + resolution: {integrity: sha512-U2fnz/H0gFPxpuC7QpaHa0Jv2AgCZ5hunp36SOP/yWo8yFzgvMh8X4pZ4uN4IKoqtBhk7G3HuVa93Urf51+sZg==} peerDependencies: eslint: '*' @@ -1462,6 +2654,11 @@ packages: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -1484,16 +2681,39 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.1: + resolution: {integrity: sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1509,6 +2729,9 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -1528,6 +2751,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -1543,25 +2770,58 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + focus-trap@8.2.0: + resolution: {integrity: sha512-CaBdQ9P4fa/yCA6pDf/3aJd8bf9IOG5QGK21/E+86o2V4V8kzXaR4A9E6tNR7KkkS1+T5ZIU1tJDBDLwsucz9g==} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + fzf@0.5.2: resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} - engines: {node: '>=18'} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -1580,15 +2840,73 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1597,9 +2915,15 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - import-without-cache@0.3.3: - resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} - engines: {node: '>=20.19.0'} + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -1609,22 +2933,64 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-builtin-module@5.0.0: resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} engines: {node: '>=18.20'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1632,6 +2998,16 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + jsdoc-type-pratt-parser@7.1.1: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} engines: {node: '>=20.0.0'} @@ -1651,9 +3027,20 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonc-eslint-parser@3.1.0: resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1668,6 +3055,36 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + langium@4.2.3: + resolution: {integrity: sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + launch-editor@2.13.2: + resolution: {integrity: sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1746,15 +3163,6 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lint-staged@17.0.2: - resolution: {integrity: sha512-Rbr6rdmbCn1fIDHBZpn0madg0hEkdlh+QwajnL3Qq0ZUq/icAJfLGj9BVBajAXi7657ZzKQ7kobGP9S5XOHYRw==} - engines: {node: '>=22.22.1'} - hasBin: true - - listr2@10.2.1: - resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} - engines: {node: '>=22.13.0'} - local-pkg@1.1.2: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} @@ -1763,25 +3171,39 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - logs-sdk@0.0.6: resolution: {integrity: sha512-G4M1C9aLLBOIWpmw/Lqk4zrap/T2IJsoUOuUDjRcVSLy6lHQqxr3wCqIT1FvvpYTUYpEwvu4utsMY42jTNvx8Q==} longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -1815,11 +3237,25 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mermaid@11.14.0: + resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -1911,27 +3347,39 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} module-replacements@2.11.0: resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nano-staged@1.0.2: + resolution: {integrity: sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw==} + engines: {node: ^22 || >= 24} + hasBin: true + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1944,21 +3392,58 @@ packages: resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} engines: {node: '>=18'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-html-parser@6.1.13: + resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + non-layered-tidy-tree-layout@2.0.2: + resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -1972,6 +3457,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -1989,6 +3478,13 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1997,16 +3493,30 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2020,6 +3530,12 @@ packages: pnpm-workspace-yaml@1.6.0: resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -2028,29 +3544,69 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + preact@10.29.1: + resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - publint@0.3.19: - resolution: {integrity: sha512-J3p4GOocCRFyLLFRzGfIhAwWgk0Kkcdxj5iFspFvCYbyiJs5IhCM8gsIkcNeQL+tdpV671RtJQiTFSUKhl1Wjg==} - engines: {node: '>=18'} - hasBin: true + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp-ast-analysis@0.7.1: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -2063,6 +3619,10 @@ packages: resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} hasBin: true + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -2070,21 +3630,17 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rolldown-plugin-dts@0.23.2: - resolution: {integrity: sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==} - engines: {node: '>=20.19.0'} + rolldown-plugin-dts@0.25.0: + resolution: {integrity: sha512-GE3uDZgUuA9l6g+1u928TRmadd5IVhaWiwpWast2kCyLv9tYJJCC6E5HHkV0HGmwC5ZL73xh12/PRZI+KZ2vdQ==} + engines: {node: ^22.18.0 || >=24.0.0} peerDependencies: '@ts-macro/tsc': ^0.3.6 '@typescript/native-preview': '>=7.0.0-dev.20260325.1' - rolldown: ^1.0.0-rc.12 - typescript: ^5.0.0 || ^6.0.0 + rolldown: ^1.0.0 + typescript: ^6.0.0 vue-tsc: ~3.2.0 peerDependenciesMeta: '@ts-macro/tsc': @@ -2096,8 +3652,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + rolldown@1.0.0: + resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2106,19 +3662,55 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2127,12 +3719,34 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} + simple-code-frame@1.3.0: + resolution: {integrity: sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==} simple-git-hooks@2.13.1: resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} @@ -2141,18 +3755,21 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} + skills-npm@1.1.1: + resolution: {integrity: sha512-qbRSzorWK3fVdpKzZquehYlWzmC29/MOh8AqWoX7NAZiCSAX/RuTpyqAml7hWBe7NM9DDx472kZ0hKO7CCXRkA==} + hasBin: true source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-exceptions@2.5.0: resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} @@ -2162,36 +3779,47 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-trace@1.0.0: + resolution: {integrity: sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==} + engines: {node: '>=20.0.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} - engines: {node: '>=20'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} strip-indent@4.1.1: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} + structured-clone-es@2.0.0: + resolution: {integrity: sha512-5UuAHmBLXYPCl22xWJrFuGmIhBKQzxISPVz6E7nmTmTcAOpUzlbjKJsRrCE4vADmMQ0dzeCnlWn9XufnAGf76Q==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -2215,6 +3843,10 @@ packages: resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} engines: {node: '>=20'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + toml-eslint-parser@1.0.3: resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2223,6 +3855,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2234,24 +3869,24 @@ packages: peerDependencies: typescript: '>=4.0.0' - tsdown-stale-guard@0.1.1: - resolution: {integrity: sha512-LeWJSseF0O4qZin0fCPfoO+8+LdbpMIP5LyVfpNy9WgvhWGPwxmn2W/9iWnY6IdXPZ1JfBp0pHTe2Bts8VVFWA==} - hasBin: true - peerDependencies: - tsdown: ^0.21.9 + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} - tsdown@0.21.10: - resolution: {integrity: sha512-3wk73yBhZe/wX7REqSdivNQ84TDs1mJ+IlnzrrEREP70xlJ/AEIzqaI04l/TzMKVIdkTdC3CPaADn2Lk/0SkdA==} - engines: {node: '>=20.19.0'} + tsdown@0.22.0: + resolution: {integrity: sha512-FgW0hHb27nGQA/+F3d5+U9wKXkfilk9DVkc5+7x/ZqF03g+Hoz/eeApT32jqxATt9eRoR+1jxk7MUMON+O4CXw==} + engines: {node: ^22.18.0 || >=24.0.0} hasBin: true peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.21.10 - '@tsdown/exe': 0.21.10 + '@tsdown/css': 0.22.0 + '@tsdown/exe': 0.22.0 '@vitejs/devtools': '*' - publint: ^0.3.0 + publint: ^0.3.8 + tsx: '*' typescript: ^5.0.0 || ^6.0.0 unplugin-unused: ^0.5.0 + unrun: '*' peerDependenciesMeta: '@arethetypeswrong/core': optional: true @@ -2263,10 +3898,14 @@ packages: optional: true publint: optional: true + tsx: + optional: true typescript: optional: true unplugin-unused: optional: true + unrun: + optional: true tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2285,15 +3924,27 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + turbo@2.9.12: + resolution: {integrity: sha512-lCPgus1NuTiBdaITWqzSH/Ff6HVL8HHGBtOXHg1dHRfcshN79XkygSdh0M6g8b0td91ILLG5MTkLOkp5UvyPJw==} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + ua-parser-modern@0.1.1: + resolution: {integrity: sha512-7w/roxPc8S0HCZi8JvmZsg5rAdjLi6Tiw1Qs5bXJkZRpSiKhEP3HObm8F/oNGB9oOaQAIGZ27p8tO82mtMcOyA==} + engines: {node: '>=20.19.0'} + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -2303,12 +3954,21 @@ packages: unconfig@7.5.0: resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} @@ -2321,19 +3981,21 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + unplugin@3.0.0: resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} engines: {node: ^20.19.0 || >=22.12.0} - unrun@0.2.37: - resolution: {integrity: sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==} - engines: {node: '>=20.19.0'} + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} hasBin: true - peerDependencies: - synckit: ^0.11.11 - peerDependenciesMeta: - synckit: - optional: true update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} @@ -2347,6 +4009,73 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + valibot@1.4.0: + resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-prerender-plugin@0.5.13: + resolution: {integrity: sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==} + peerDependencies: + vite: 5.x || 6.x || 7.x || 8.x + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.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 + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@8.0.11: resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2390,6 +4119,27 @@ packages: yaml: optional: true + vitepress-plugin-mermaid@2.0.17: + resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} + peerDependencies: + mermaid: 10 || 11 + vitepress: ^1.0.0 || ^1.0.0-alpha + + vitepress@2.0.0-alpha.17: + resolution: {integrity: sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + oxc-minify: '*' + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + oxc-minify: + optional: true + postcss: + optional: true + vitest@4.1.5: resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2431,15 +4181,46 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-eslint-parser@10.4.0: resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whenexpr@0.1.2: + resolution: {integrity: sha512-VVSufnKy206AoAYrxiZ0xof2nx0fqHCFYi7Ox/kAn7azSHsEc4geVlENPb4Nq8mKKlYPRKQrgIQbjrWldMvGCg==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2454,18 +4235,36 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} + xdg-basedir@5.1.0: + resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} + engines: {node: '>=12'} xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-eslint-parser@2.0.0: resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2479,12 +4278,27 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3))(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)))': + '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3))(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.3.0 @@ -2494,14 +4308,14 @@ snapshots: '@stylistic/eslint-plugin': 5.10.0(eslint@10.3.0(jiti@2.7.0)) '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) - '@vitest/eslint-plugin': 1.6.16(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))) ansis: 4.2.0 cac: 7.0.0 eslint: 10.3.0(jiti@2.7.0) eslint-config-flat-gitignore: 2.3.0(eslint@10.3.0(jiti@2.7.0)) eslint-flat-config-utils: 3.2.0 eslint-merge-processors: 2.0.0(eslint@10.3.0(jiti@2.7.0)) - eslint-plugin-antfu: 3.2.2(eslint@10.3.0(jiti@2.7.0)) + eslint-plugin-antfu: 3.2.3(eslint@10.3.0(jiti@2.7.0)) eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3))(@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0)) eslint-plugin-import-lite: 0.6.0(eslint@10.3.0(jiti@2.7.0)) eslint-plugin-jsdoc: 62.9.0(eslint@10.3.0(jiti@2.7.0)) @@ -2548,40 +4362,176 @@ snapshots: '@antfu/utils@9.3.0': {} - '@babel/generator@8.0.0-rc.3': + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 7.8.0 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 8.0.0-rc.3 - '@babel/types': 8.0.0-rc.3 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/generator@8.0.0-rc.4': + dependencies: + '@babel/parser': 8.0.0-rc.4 + '@babel/types': 8.0.0-rc.4 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 jsesc: 3.1.0 - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 7.8.0 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-string-parser@8.0.0-rc.4': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-identifier@8.0.0-rc.4': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/parser@8.0.0-rc.4': + dependencies: + '@babel/types': 8.0.0-rc.4 - '@babel/helper-string-parser@8.0.0-rc.4': {} + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color - '@babel/helper-validator-identifier@8.0.0-rc.3': {} + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color - '@babel/parser@7.29.3': + '@babel/template@7.28.6': dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 - '@babel/parser@8.0.0-rc.3': + '@babel/traverse@7.29.0': dependencies: - '@babel/types': 8.0.0-rc.3 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@8.0.0-rc.3': + '@babel/types@8.0.0-rc.4': dependencies: '@babel/helper-string-parser': 8.0.0-rc.4 - '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.4 + + '@braintree/sanitize-url@6.0.4': + optional: true + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/cst-dts-gen@12.0.0': + dependencies: + '@chevrotain/gast': 12.0.0 + '@chevrotain/types': 12.0.0 + + '@chevrotain/gast@12.0.0': + dependencies: + '@chevrotain/types': 12.0.0 + + '@chevrotain/regexp-to-ast@12.0.0': {} + + '@chevrotain/types@12.0.0': {} + + '@chevrotain/utils@12.0.0': {} '@clack/core@1.3.0': dependencies: @@ -2595,6 +4545,12 @@ snapshots: fast-wrap-ansi: 0.2.0 sisteransi: 1.0.5 + '@docsearch/css@4.6.3': {} + + '@docsearch/js@4.6.3': {} + + '@docsearch/sidepanel-js@4.6.3': {} + '@e18e/eslint-plugin@0.3.0(eslint@10.3.0(jiti@2.7.0))': dependencies: eslint-plugin-depend: 1.5.0(eslint@10.3.0(jiti@2.7.0)) @@ -2737,7 +4693,7 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@2.0.5(eslint@10.3.0(jiti@2.7.0))': + '@eslint/compat@2.1.0(eslint@10.3.0(jiti@2.7.0))': dependencies: '@eslint/core': 1.2.1 optionalDependencies: @@ -2787,6 +4743,10 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2803,6 +4763,18 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/simple-icons@1.2.81': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2822,6 +4794,43 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/mermaid-mindmap@9.3.0': + dependencies: + '@braintree/sanitize-url': 6.0.4 + cytoscape: 3.33.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.3) + cytoscape-fcose: 2.2.0(cytoscape@3.33.3) + d3: 7.9.0 + khroma: 2.1.0 + non-layered-tidy-tree-layout: 2.0.2 + optional: true + + '@mermaid-js/parser@1.1.0': + dependencies: + langium: 4.2.3 + + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.18) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.1(express@5.2.1) + hono: 4.12.18 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2836,6 +4845,31 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nuxt/kit@4.4.5': + dependencies: + c12: 3.3.4 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + errx: 0.1.0 + exsolve: 1.0.8 + ignore: 7.0.5 + jiti: 2.7.0 + klona: 2.0.6 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.1 + rc9: 3.0.1 + scule: 1.3.0 + semver: 7.8.0 + tinyglobby: 0.2.16 + ufo: 1.6.4 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + '@ota-meshi/ast-token-store@0.3.0': {} '@oxc-parser/binding-android-arm-eabi@0.126.0': @@ -2904,91 +4938,128 @@ snapshots: '@oxc-project/types@0.126.0': {} - '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.128.0': {} + '@oxc-project/types@0.129.0': {} + '@pkgr/core@0.2.9': {} - '@publint/pack@0.1.4': {} + '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.3)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) + debug: 4.4.3 + magic-string: 0.30.21 + picocolors: 1.1.1 + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite-prerender-plugin: 0.5.13(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + zimmerframe: 1.1.4 + transitivePeerDependencies: + - preact + - rollup + - supports-color + + '@prefresh/babel-plugin@0.5.3': {} + + '@prefresh/core@1.5.9(preact@10.29.1)': + dependencies: + preact: 10.29.1 + + '@prefresh/utils@1.2.1': {} + + '@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@babel/core': 7.29.0 + '@prefresh/babel-plugin': 0.5.3 + '@prefresh/core': 1.5.9(preact@10.29.1) + '@prefresh/utils': 1.2.1 + '@rollup/pluginutils': 4.2.1 + preact: 10.29.1 + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + transitivePeerDependencies: + - supports-color '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 - '@rolldown/binding-android-arm64@1.0.0-rc.17': + '@rolldown/binding-android-arm64@1.0.0': optional: true '@rolldown/binding-android-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + '@rolldown/binding-darwin-arm64@1.0.0': optional: true '@rolldown/binding-darwin-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.17': + '@rolldown/binding-darwin-x64@1.0.0': optional: true '@rolldown/binding-darwin-x64@1.0.0-rc.18': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + '@rolldown/binding-freebsd-x64@1.0.0': optional: true '@rolldown/binding-freebsd-x64@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-gnu@1.0.0': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-musl@1.0.0': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-ppc64-gnu@1.0.0': optional: true '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-s390x-gnu@1.0.0': optional: true '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-x64-gnu@1.0.0': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-x64-musl@1.0.0': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + '@rolldown/binding-openharmony-arm64@1.0.0': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + '@rolldown/binding-wasm32-wasi@1.0.0': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -3002,22 +5073,150 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-arm64-msvc@1.0.0': optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-x64-msvc@1.0.0': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': optional: true - '@rolldown/pluginutils@1.0.0-rc.17': {} + '@rolldown/pluginutils@1.0.0': {} + + '@rolldown/pluginutils@1.0.0-rc.13': {} '@rolldown/pluginutils@1.0.0-rc.18': {} + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.2 + + '@rollup/pluginutils@5.3.0(rollup@4.60.3)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/base62@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -3032,6 +5231,24 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@turbo/darwin-64@2.9.12': + optional: true + + '@turbo/darwin-arm64@2.9.12': + optional: true + + '@turbo/linux-64@2.9.12': + optional: true + + '@turbo/linux-arm64@2.9.12': + optional: true + + '@turbo/windows-64@2.9.12': + optional: true + + '@turbo/windows-arm64@2.9.12': + optional: true + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -3042,6 +5259,123 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -3050,8 +5384,12 @@ snapshots: '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -3062,18 +5400,36 @@ snapshots: '@types/katex@0.16.8': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} - '@types/node@25.6.0': + '@types/node@25.6.2': dependencies: undici-types: 7.19.2 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} + '@types/web-bluetooth@0.0.21': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.2 + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3120,7 +5476,7 @@ snapshots: eslint: 10.3.0(jiti@2.7.0) json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - semver: 7.7.4 + semver: 7.8.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3156,7 +5512,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -3179,7 +5535,24 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 - '@vitest/eslint-plugin@1.6.16(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)))': + '@ungap/structured-clone@1.3.1': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@valibot/to-json-schema@1.7.0(valibot@1.4.0(typescript@6.0.3))': + dependencies: + valibot: 1.4.0(typescript@6.0.3) + + '@vitejs/plugin-vue@6.0.6(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) + vue: 3.5.34(typescript@6.0.3) + + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) @@ -3187,7 +5560,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - supports-color @@ -3200,13 +5573,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.5(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -3245,31 +5618,97 @@ snapshots: '@vue/compiler-core': 3.5.34 '@vue/shared': 3.5.34 - '@vue/compiler-sfc@3.5.34': + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.14 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/devtools-api@8.1.2': + dependencies: + '@vue/devtools-kit': 8.1.2 + + '@vue/devtools-kit@8.1.2': + dependencies: + '@vue/devtools-shared': 8.1.2 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.1.2': {} + + '@vue/reactivity@3.5.34': + dependencies: + '@vue/shared': 3.5.34 + + '@vue/runtime-core@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/runtime-dom@3.5.34': dependencies: - '@babel/parser': 7.29.3 - '@vue/compiler-core': 3.5.34 - '@vue/compiler-dom': 3.5.34 - '@vue/compiler-ssr': 3.5.34 + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 '@vue/shared': 3.5.34 - estree-walker: 2.0.2 - magic-string: 0.30.21 - postcss: 8.5.14 - source-map-js: 1.2.1 + csstype: 3.2.3 - '@vue/compiler-ssr@3.5.34': + '@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3))': dependencies: - '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 '@vue/shared': 3.5.34 + vue: 3.5.34(typescript@6.0.3) '@vue/shared@3.5.34': {} + '@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@6.0.3)) + vue: 3.5.34(typescript@6.0.3) + + '@vueuse/integrations@14.3.0(change-case@5.4.4)(focus-trap@8.2.0)(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3)) + '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@6.0.3)) + vue: 3.5.34(typescript@6.0.3) + optionalDependencies: + change-case: 5.4.4 + focus-trap: 8.2.0 + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + vue: 3.5.34(typescript@6.0.3) + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3277,64 +5716,118 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ansi-escapes@7.3.0: + ajv@8.20.0: dependencies: - environment: 1.1.0 - - ansi-regex@6.2.2: {} - - ansi-styles@6.2.3: {} + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 ansis@4.2.0: {} are-docs-informative@0.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + args-tokenizer@0.3.0: {} assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: dependencies: - '@babel/parser': 8.0.0-rc.3 + '@babel/parser': 8.0.0-rc.4 estree-walker: 3.0.3 pathe: 2.0.3 + babel-plugin-transform-hook-names@1.0.2(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.27: {} + baseline-browser-mapping@2.10.29: {} + + birpc@2.9.0: {} birpc@4.0.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.27 + baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.352 + electron-to-chromium: 1.5.353 node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) - builtin-modules@5.1.0: {} + builtin-modules@5.2.0: {} - bumpp@11.0.1: + bumpp@11.1.0: dependencies: args-tokenizer: 0.3.0 cac: 7.0.0 jsonc-parser: 3.3.1 package-manager-detector: 1.6.0 - semver: 7.7.4 + semver: 7.8.0 tinyexec: 1.1.2 tinyglobby: 0.2.16 unconfig: 7.5.0 yaml: 2.8.4 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.2.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001792: {} ccount@2.0.1: {} @@ -3343,22 +5836,42 @@ snapshots: change-case@5.4.4: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + character-entities@2.0.2: {} + chevrotain-allstar@0.4.3(chevrotain@12.0.0): + dependencies: + chevrotain: 12.0.0 + lodash-es: 4.18.1 + + chevrotain@12.0.0: + dependencies: + '@chevrotain/cst-dts-gen': 12.0.0 + '@chevrotain/gast': 12.0.0 + '@chevrotain/regexp-to-ast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/utils': 12.0.0 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + ci-info@4.4.0: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + clean-regexp@1.0.0: dependencies: escape-string-regexp: 1.0.5 - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 + comma-separated-tokens@2.0.3: {} - cli-truncate@5.2.0: - dependencies: - slice-ansi: 8.0.0 - string-width: 8.2.1 + commander@7.2.0: {} commander@8.3.0: {} @@ -3370,20 +5883,247 @@ snapshots: confbox@0.2.4: {} + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-es@1.2.3: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-js-compat@3.49.0: dependencies: browserslist: 4.28.2 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + cssesc@3.0.0: {} + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.3): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.3 + + cytoscape-fcose@2.2.0(cytoscape@3.33.3): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.3 + + cytoscape@3.33.3: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + dayjs@1.11.20: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3394,10 +6134,27 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} devlop@1.1.0: @@ -3406,25 +6163,67 @@ snapshots: diff-sequences@29.6.3: {} - dts-resolver@2.1.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} - electron-to-chromium@1.5.352: {} + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.4.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv@17.4.2: {} + + dts-resolver@3.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} - emoji-regex@10.6.0: {} + electron-to-chromium@1.5.353: {} - empathic@2.0.0: {} + empathic@2.0.1: {} - enhanced-resolve@5.21.0: + encodeurl@2.0.0: {} + + enhanced-resolve@5.21.2: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@4.5.0: {} + entities@7.0.1: {} - environment@1.1.0: {} + errx@0.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -3456,6 +6255,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -3465,11 +6266,11 @@ snapshots: eslint-compat-utils@0.5.1(eslint@10.3.0(jiti@2.7.0)): dependencies: eslint: 10.3.0(jiti@2.7.0) - semver: 7.7.4 + semver: 7.8.0 eslint-config-flat-gitignore@2.3.0(eslint@10.3.0(jiti@2.7.0)): dependencies: - '@eslint/compat': 2.0.5(eslint@10.3.0(jiti@2.7.0)) + '@eslint/compat': 2.1.0(eslint@10.3.0(jiti@2.7.0)) eslint: 10.3.0(jiti@2.7.0) eslint-flat-config-utils@3.2.0: @@ -3487,7 +6288,7 @@ snapshots: dependencies: eslint: 10.3.0(jiti@2.7.0) - eslint-plugin-antfu@3.2.2(eslint@10.3.0(jiti@2.7.0)): + eslint-plugin-antfu@3.2.3(eslint@10.3.0(jiti@2.7.0)): dependencies: eslint: 10.3.0(jiti@2.7.0) @@ -3501,10 +6302,10 @@ snapshots: eslint-plugin-depend@1.5.0(eslint@10.3.0(jiti@2.7.0)): dependencies: - empathic: 2.0.0 + empathic: 2.0.1 eslint: 10.3.0(jiti@2.7.0) module-replacements: 2.11.0 - semver: 7.7.4 + semver: 7.8.0 eslint-plugin-es-x@7.8.0(eslint@10.3.0(jiti@2.7.0)): dependencies: @@ -3531,7 +6332,7 @@ snapshots: html-entities: 2.6.0 object-deep-merge: 2.0.0 parse-imports-exports: 0.2.4 - semver: 7.7.4 + semver: 7.8.0 spdx-expression-parse: 4.0.0 to-valid-identifier: 1.0.0 transitivePeerDependencies: @@ -3555,14 +6356,14 @@ snapshots: eslint-plugin-n@17.24.0(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) - enhanced-resolve: 5.21.0 + enhanced-resolve: 5.21.2 eslint: 10.3.0(jiti@2.7.0) eslint-plugin-es-x: 7.8.0(eslint@10.3.0(jiti@2.7.0)) get-tsconfig: 4.14.0 globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 - semver: 7.7.4 + semver: 7.8.0 ts-declaration-location: 1.0.7(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -3580,7 +6381,7 @@ snapshots: eslint-plugin-pnpm@1.6.0(eslint@10.3.0(jiti@2.7.0)): dependencies: - empathic: 2.0.0 + empathic: 2.0.1 eslint: 10.3.0(jiti@2.7.0) jsonc-eslint-parser: 3.1.0 pathe: 2.0.3 @@ -3628,7 +6429,7 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.1 - semver: 7.7.4 + semver: 7.8.0 strip-indent: 4.1.1 eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0)): @@ -3644,7 +6445,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 - semver: 7.7.4 + semver: 7.8.0 vue-eslint-parser: 10.4.0(eslint@10.3.0(jiti@2.7.0)) xml-name-validator: 4.0.0 optionalDependencies: @@ -3729,6 +6530,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -3747,12 +6550,60 @@ snapshots: esutils@2.0.3: {} - eventemitter3@5.0.4: {} + etag@1.8.1: {} + + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 expect-type@1.3.0: {} + express-rate-limit@8.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -3765,6 +6616,8 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.2: {} + fast-wrap-ansi@0.2.0: dependencies: fast-string-width: 3.0.2 @@ -3781,6 +6634,17 @@ snapshots: dependencies: flat-cache: 4.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up-simple@1.0.1: {} find-up@5.0.0: @@ -3795,19 +6659,55 @@ snapshots: flatted@3.4.2: {} + focus-trap@8.2.0: + dependencies: + tabbable: 6.4.0 + format@0.2.2: {} + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + fzf@0.5.2: {} - get-east-asian-width@1.5.0: {} + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-port-please@3.2.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@3.2.0: {} + github-slugger@2.0.0: {} glob-parent@6.0.2: @@ -3820,40 +6720,150 @@ snapshots: globrex@0.1.2: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hachure-fill@0.5.2: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + he@1.2.0: {} + + hono@4.12.18: {} + + hookable@5.5.3: {} + hookable@6.1.1: {} html-entities@2.6.0: {} + html-void-elements@3.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-id@4.1.3: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} - import-without-cache@0.3.3: {} + immer@11.1.8: {} + + import-meta-resolve@4.2.0: {} + + import-without-cache@0.4.0: {} imurmurhash@0.1.4: {} indent-string@5.0.0: {} + inherits@2.0.4: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + iron-webcrypto@1.2.1: {} + is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.1.0 + builtin-modules: 5.2.0 + + is-docker@3.0.0: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 - is-extglob@2.1.1: {} + is-in-ssh@1.0.0: {} - is-fullwidth-code-point@5.1.0: + is-inside-container@1.0.0: dependencies: - get-east-asian-width: 1.5.0 + is-docker: 3.0.0 - is-glob@4.0.3: + is-promise@4.0.0: {} + + is-wsl@3.1.1: dependencies: - is-extglob: 2.1.1 + is-inside-container: 1.0.0 isexe@2.0.0: {} jiti@2.7.0: {} + jose@6.2.3: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + jsdoc-type-pratt-parser@7.1.1: {} jsdoc-type-pratt-parser@7.2.0: {} @@ -3864,13 +6874,19 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + jsonc-eslint-parser@3.1.0: dependencies: acorn: 8.16.0 eslint-visitor-keys: 5.0.1 - semver: 7.7.4 + semver: 7.8.0 jsonc-parser@3.3.1: {} @@ -3882,6 +6898,34 @@ snapshots: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + + kind-of@6.0.3: {} + + klona@2.0.6: {} + + knitwork@1.3.0: {} + + kolorist@1.8.0: {} + + langium@4.2.3: + dependencies: + '@chevrotain/regexp-to-ast': 12.0.0 + chevrotain: 12.0.0 + chevrotain-allstar: 0.4.3(chevrotain@12.0.0) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + launch-editor@2.13.2: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.8.3 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -3936,23 +6980,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lint-staged@17.0.2: - dependencies: - listr2: 10.2.1 - picomatch: 4.0.4 - string-argv: 0.3.2 - tinyexec: 1.1.2 - optionalDependencies: - yaml: 2.8.4 - - listr2@10.2.1: - dependencies: - cli-truncate: 5.2.0 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 10.0.0 - local-pkg@1.1.2: dependencies: mlly: 1.8.2 @@ -3963,15 +6990,9 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.merge@4.6.2: {} + lodash-es@4.18.1: {} - log-update@6.1.0: - dependencies: - ansi-escapes: 7.3.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 + lodash.merge@4.6.2: {} logs-sdk@0.0.6: dependencies: @@ -3981,12 +7002,22 @@ snapshots: longest-streak@3.1.0: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mark.js@8.11.1: {} + markdown-table@3.0.4: {} + marked@16.4.2: {} + + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -4096,6 +7127,18 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -4112,6 +7155,34 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mermaid@11.14.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.1.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.3) + cytoscape-fcose: 2.2.0(cytoscape@3.33.3) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.4.2 + katex: 0.16.45 + khroma: 2.1.0 + lodash-es: 4.18.1 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.2.0 + uuid: 11.1.1 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -4320,11 +7391,17 @@ snapshots: transitivePeerDependencies: - supports-color - mimic-function@5.0.1: {} + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 + + minisearch@7.2.0: {} mlly@1.8.2: dependencies: @@ -4335,29 +7412,70 @@ snapshots: module-replacements@2.11.0: {} - mri@1.2.0: {} + mrmime@2.0.1: {} ms@2.1.3: {} + nano-staged@1.0.2: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} natural-orderby@5.0.0: {} + negotiator@1.0.0: {} + + node-html-parser@6.1.13: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + + node-mock-http@1.0.4: {} + node-releases@2.0.38: {} + non-layered-tidy-tree-layout@2.0.2: + optional: true + nth-check@2.1.1: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + object-deep-merge@2.0.0: {} + object-inspect@1.13.4: {} + obug@2.1.1: {} - onetime@7.0.0: + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: dependencies: - mimic-function: 5.0.1 + wrappy: 1.0.2 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 optionator@0.9.4: dependencies: @@ -4397,6 +7515,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -4411,16 +7533,28 @@ snapshots: parse-statements@1.0.11: {} + parseurl@1.3.3: {} + + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -4439,6 +7573,13 @@ snapshots: dependencies: yaml: 2.8.4 + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -4450,25 +7591,61 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + + preact@10.29.1: {} + prelude-ls@1.2.1: {} - publint@0.3.19: + property-information@7.1.0: {} + + proxy-addr@2.0.7: dependencies: - '@publint/pack': 0.1.4 - package-manager-detector: 1.6.0 - picocolors: 1.1.1 - sade: 1.8.1 + forwarded: 0.2.0 + ipaddr.js: 1.9.1 punycode@2.3.1: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} quansync@1.0.0: {} + radix3@1.1.2: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@5.0.0: {} + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.12.2 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp-ast-analysis@0.7.1: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4480,55 +7657,50 @@ snapshots: dependencies: jsesc: 3.1.0 + require-from-string@2.0.2: {} + reserved-identifiers@1.2.0: {} resolve-pkg-maps@1.0.0: {} - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - - rfdc@1.4.1: {} + robust-predicates@3.0.3: {} - rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.17)(typescript@6.0.3): + rolldown-plugin-dts@0.25.0(rolldown@1.0.0)(typescript@6.0.3): dependencies: - '@babel/generator': 8.0.0-rc.3 - '@babel/helper-validator-identifier': 8.0.0-rc.3 - '@babel/parser': 8.0.0-rc.3 - '@babel/types': 8.0.0-rc.3 + '@babel/generator': 8.0.0-rc.4 + '@babel/helper-validator-identifier': 8.0.0-rc.4 + '@babel/parser': 8.0.0-rc.4 ast-kit: 3.0.0-beta.1 birpc: 4.0.0 - dts-resolver: 2.1.3 - get-tsconfig: 4.14.0 + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 obug: 2.1.1 - picomatch: 4.0.4 - rolldown: 1.0.0-rc.17 + rolldown: 1.0.0 optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.17: + rolldown@1.0.0: dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 + '@oxc-project/types': 0.129.0 + '@rolldown/pluginutils': 1.0.0 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + '@rolldown/binding-android-arm64': 1.0.0 + '@rolldown/binding-darwin-arm64': 1.0.0 + '@rolldown/binding-darwin-x64': 1.0.0 + '@rolldown/binding-freebsd-x64': 1.0.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0 + '@rolldown/binding-linux-arm64-gnu': 1.0.0 + '@rolldown/binding-linux-arm64-musl': 1.0.0 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0 + '@rolldown/binding-linux-s390x-gnu': 1.0.0 + '@rolldown/binding-linux-x64-gnu': 1.0.0 + '@rolldown/binding-linux-x64-musl': 1.0.0 + '@rolldown/binding-openharmony-arm64': 1.0.0 + '@rolldown/binding-wasm32-wasi': 1.0.0 + '@rolldown/binding-win32-arm64-msvc': 1.0.0 + '@rolldown/binding-win32-x64-msvc': 1.0.0 rolldown@1.0.0-rc.18: dependencies: @@ -4551,9 +7723,59 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 - sade@1.8.1: + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + router@2.2.0: dependencies: - mri: 1.2.0 + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} scslre@0.3.0: dependencies: @@ -4561,7 +7783,41 @@ snapshots: refa: 0.12.1 regexp-ast-analysis: 0.7.1 - semver@7.7.4: {} + scule@1.3.0: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + semver@7.8.0: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} shebang-command@2.0.0: dependencies: @@ -4569,26 +7825,74 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} - signal-exit@4.1.0: {} + simple-code-frame@1.3.0: + dependencies: + kolorist: 1.8.0 simple-git-hooks@2.13.1: {} sisteransi@1.0.5: {} - slice-ansi@7.1.2: + skills-npm@1.1.1: dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 + '@clack/prompts': 1.3.0 + cac: 7.0.0 + gray-matter: 4.0.3 + picocolors: 1.1.1 + tinyglobby: 0.2.16 + unconfig: 7.5.0 + xdg-basedir: 5.1.0 + yaml: 2.8.4 source-map-js@1.2.1: {} + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + spdx-exceptions@2.5.0: {} spdx-expression-parse@4.0.0: @@ -4598,33 +7902,35 @@ snapshots: spdx-license-ids@3.0.23: {} - stackback@0.0.2: {} + sprintf-js@1.0.3: {} - std-env@4.1.0: {} + stack-trace@1.0.0: {} - string-argv@0.3.2: {} + stackback@0.0.2: {} - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 + statuses@2.0.2: {} - string-width@8.2.1: - dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 + std-env@4.1.0: {} - strip-ansi@7.2.0: + stringify-entities@4.0.4: dependencies: - ansi-regex: 6.2.2 + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-bom-string@1.0.0: {} strip-indent@4.1.1: {} + structured-clone-es@2.0.0: {} + + stylis@4.4.0: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 + tabbable@6.4.0: {} + tapable@2.3.3: {} tinybench@2.9.0: {} @@ -4643,12 +7949,16 @@ snapshots: '@sindresorhus/base62': 1.0.0 reserved-identifiers: 1.2.0 + toidentifier@1.0.1: {} + toml-eslint-parser@1.0.3: dependencies: eslint-visitor-keys: 5.0.1 tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -4658,45 +7968,38 @@ snapshots: picomatch: 4.0.4 typescript: 6.0.3 - tsdown-stale-guard@0.1.1(tsdown@0.21.10(publint@0.3.19)(synckit@0.11.12)(typescript@6.0.3)): - dependencies: - cac: 7.0.0 - logs-sdk: 0.0.6 - tsdown: 0.21.10(publint@0.3.19)(synckit@0.11.12)(typescript@6.0.3) - yaml: 2.8.4 + ts-dedent@2.2.0: {} - tsdown@0.21.10(publint@0.3.19)(synckit@0.11.12)(typescript@6.0.3): + tsdown@0.22.0(tsx@4.21.0)(typescript@6.0.3): dependencies: ansis: 4.2.0 cac: 7.0.0 defu: 6.1.7 - empathic: 2.0.0 + empathic: 2.0.1 hookable: 6.1.1 - import-without-cache: 0.3.3 + import-without-cache: 0.4.0 obug: 2.1.1 picomatch: 4.0.4 - rolldown: 1.0.0-rc.17 - rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.17)(typescript@6.0.3) - semver: 7.7.4 + rolldown: 1.0.0 + rolldown-plugin-dts: 0.25.0(rolldown@1.0.0)(typescript@6.0.3) + semver: 7.8.0 tinyexec: 1.1.2 tinyglobby: 0.2.16 tree-kill: 1.2.2 unconfig-core: 7.5.0 - unrun: 0.2.37(synckit@0.11.12) optionalDependencies: - publint: 0.3.19 + tsx: 4.21.0 typescript: 6.0.3 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' - oxc-resolver - - synckit - vue-tsc tslib@2.8.1: optional: true - tsnapi@0.3.2(vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))): + tsnapi@0.3.2(vitest@4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))): dependencies: '@vitest/utils': 4.1.5 cac: 7.0.0 @@ -4704,7 +8007,7 @@ snapshots: oxc-parser: 0.126.0 tinyglobby: 0.2.16 optionalDependencies: - vitest: 4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) tsx@4.21.0: dependencies: @@ -4713,12 +8016,29 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turbo@2.9.12: + optionalDependencies: + '@turbo/darwin-64': 2.9.12 + '@turbo/darwin-arm64': 2.9.12 + '@turbo/linux-64': 2.9.12 + '@turbo/linux-arm64': 2.9.12 + '@turbo/windows-64': 2.9.12 + '@turbo/windows-arm64': 2.9.12 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@6.0.3: {} + ua-parser-modern@0.1.1: {} + ufo@1.6.4: {} unconfig-core@7.5.0: @@ -4734,12 +8054,25 @@ snapshots: quansync: 1.0.0 unconfig-core: 7.5.0 + uncrypto@0.1.3: {} + + unctx@2.5.0: + dependencies: + acorn: 8.16.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + undici-types@7.19.2: {} unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -4760,17 +8093,28 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + unplugin@3.0.0: dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - unrun@0.2.37(synckit@0.11.12): + untyped@2.0.0: dependencies: - rolldown: 1.0.0-rc.17 - optionalDependencies: - synckit: 0.11.12 + citty: 0.1.6 + defu: 6.1.7 + jiti: 2.7.0 + knitwork: 1.3.0 + scule: 1.3.0 update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -4784,7 +8128,51 @@ snapshots: util-deprecate@1.0.2: {} - vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): + uuid@11.1.1: {} + + valibot@1.4.0(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + vary@1.1.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-prerender-plugin@0.5.13(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): + dependencies: + kolorist: 1.8.0 + magic-string: 0.30.21 + node-html-parser: 6.1.13 + simple-code-frame: 1.3.0 + source-map: 0.7.6 + stack-trace: 1.0.0 + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + + vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.2 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + tsx: 4.21.0 + yaml: 2.8.4 + + vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4792,17 +8180,72 @@ snapshots: rolldown: 1.0.0-rc.18 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.21.0 yaml: 2.8.4 - vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): + vitepress-plugin-mermaid@2.0.17(mermaid@11.14.0)(vitepress@2.0.0-alpha.17(@types/node@25.6.2)(change-case@5.4.4)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.4)): + dependencies: + mermaid: 11.14.0 + vitepress: 2.0.0-alpha.17(@types/node@25.6.2)(change-case@5.4.4)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.4) + optionalDependencies: + '@mermaid-js/mermaid-mindmap': 9.3.0 + + vitepress@2.0.0-alpha.17(@types/node@25.6.2)(change-case@5.4.4)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.4): + dependencies: + '@docsearch/css': 4.6.3 + '@docsearch/js': 4.6.3 + '@docsearch/sidepanel-js': 4.6.3 + '@iconify-json/simple-icons': 1.2.81 + '@shikijs/core': 3.23.0 + '@shikijs/transformers': 3.23.0 + '@shikijs/types': 3.23.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 6.0.6(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3)) + '@vue/devtools-api': 8.1.2 + '@vue/shared': 3.5.34 + '@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3)) + '@vueuse/integrations': 14.3.0(change-case@5.4.4)(focus-trap@8.2.0)(vue@3.5.34(typescript@6.0.3)) + focus-trap: 8.2.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 3.23.0 + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) + vue: 3.5.34(typescript@6.0.3) + optionalDependencies: + postcss: 8.5.14 + transitivePeerDependencies: + - '@types/node' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jiti + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - sass + - sass-embedded + - sortablejs + - stylus + - sugarss + - terser + - tsx + - typescript + - universal-cookie + - yaml + + vitest@4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.5(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -4819,13 +8262,30 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.11(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 transitivePeerDependencies: - msw + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0)): dependencies: debug: 4.4.3 @@ -4834,12 +8294,24 @@ snapshots: eslint-visitor-keys: 5.0.1 espree: 11.2.0 esquery: 1.7.0 - semver: 7.7.4 + semver: 7.8.0 transitivePeerDependencies: - supports-color + vue@3.5.34(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) + '@vue/shared': 3.5.34 + optionalDependencies: + typescript: 6.0.3 + webpack-virtual-modules@0.6.2: {} + whenexpr@0.1.2: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4851,20 +8323,21 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@10.0.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.1 - strip-ansi: 7.2.0 + wrappy@1.0.2: {} - wrap-ansi@9.0.2: + ws@8.20.0: {} + + wsl-utils@0.3.1: dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xdg-basedir@5.1.0: {} xml-name-validator@4.0.0: {} + yallist@3.1.1: {} + yaml-eslint-parser@2.0.0: dependencies: eslint-visitor-keys: 5.0.1 @@ -4874,4 +8347,14 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + + zimmerframe@1.1.4: {} + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 00da69b..9551b19 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,35 +1,76 @@ +allowBuilds: + esbuild: true + simple-git-hooks: true + unrs-resolver: true + catalogMode: prefer + +cleanupUnusedCatalogs: true ignoreWorkspaceRootCheck: true + +shamefullyHoist: true shellEmulator: true +strictPeerDependencies: false trustPolicy: no-downgrade - packages: - - playground - - docs - packages/* - examples/* - + - docs +overrides: + semver: ^7.8.0 catalogs: - cli: - '@antfu/eslint-config': ^8.2.0 + build: '@antfu/ni': ^30.1.0 - bumpp: ^11.0.1 + '@nuxt/kit': ^4.4.5 + '@preact/preset-vite': ^2.10.5 + tsdown: ^0.22.0 + tsx: ^4.21.0 + turbo: ^2.9.12 + vite: ^8.0.11 + deps: + '@modelcontextprotocol/sdk': ^1.29.0 + '@valibot/to-json-schema': ^1.7.0 + ansis: ^4.2.0 + birpc: ^4.0.0 + cac: ^7.0.0 + get-port-please: ^3.2.0 + h3: ^1.15.11 + immer: ^11.1.8 + launch-editor: ^2.13.2 + logs-sdk: ^0.0.6 + mrmime: ^2.0.1 + obug: ^2.1.1 + ohash: ^2.0.11 + open: ^11.0.0 + p-limit: ^7.3.0 + pathe: ^2.0.3 + perfect-debounce: ^2.1.0 + structured-clone-es: ^2.0.0 + tinyglobby: ^0.2.16 + valibot: ^1.4.0 + whenexpr: ^0.1.2 + ws: ^8.20.0 + devtools: + '@antfu/eslint-config': ^8.2.0 + bumpp: ^11.1.0 eslint: ^10.3.0 - lint-staged: ^17.0.2 - publint: ^0.3.19 + nano-staged: ^1.0.2 simple-git-hooks: ^2.13.1 - tsdown: ^0.21.10 - tsx: ^4.21.0 + skills-npm: ^1.1.1 typescript: ^6.0.3 - vite: ^8.0.11 + docs: + mermaid: ^11.14.0 + vitepress: ^2.0.0-alpha.17 + vitepress-plugin-mermaid: ^2.0.17 + frontend: + preact: ^10.29.1 inlined: '@antfu/utils': ^9.3.0 + human-id: ^4.1.3 + ua-parser-modern: ^0.1.1 testing: - tsdown-stale-guard: ^0.1.1 tsnapi: ^0.3.2 vitest: ^4.1.5 types: '@types/node': ^25.6.0 -onlyBuiltDependencies: - - esbuild - - simple-git-hooks + '@types/ws': ^8.18.1 diff --git a/skills/devframe/README.md b/skills/devframe/README.md new file mode 100644 index 0000000..3231167 --- /dev/null +++ b/skills/devframe/README.md @@ -0,0 +1,13 @@ +# devframe skill + +An agent skill that teaches Claude Code how to build a full-featured +devtool with devframe. + +## Opt-in + +Until devframe publishes an installable skill, link this directory from +your Claude Code skills path. Example for the CLI: + +```sh +ln -s "$PWD/devframe/skills/devframe" "$HOME/.claude/skills/devframe" +``` diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md new file mode 100644 index 0000000..b7e5d8f --- /dev/null +++ b/skills/devframe/SKILL.md @@ -0,0 +1,428 @@ +--- +name: devframe +description: > + Use when building one devtool integration with devframe — the + portable, framework-neutral container for a single tool. Covers + DevframeDefinition, picking the right deployment adapter + (cli / build / spa / vite / embedded / mcp), designing RPC + contracts, exposing an agent-native surface over MCP, and wiring + the author's SPA client. Hub-only concerns (docks, terminals, + commands, the unified messages dock) belong to + `@vitejs/devtools-kit` — see the `vite-devtools-kit` skill for + those. Triggers on `devframe` imports, `defineDevframe`, + `createCli`, `createMcpServer`, `connectDevframe`, and on + migrations of existing inspectors (eslint-config-inspector, + unocss-inspector, node-modules-inspector-style tools) to devframe. +--- + +# devframe skill + +**Devframe is the container for one devtool integration, portable across viewers.** A devtool built on devframe is a single `DevframeDefinition` plus an author-provided SPA — the same definition deploys as a standalone CLI, a static report, an embedded SPA, an MCP server, or as a dock entry inside the Vite DevTools hub via `createPluginFromDevframe`. + +Devframe deliberately stops at the boundary of one tool. Anything that only matters across multiple integrations — docks, terminals, command palette, cross-tool toasts — lives in `@vitejs/devtools-kit`, the hub layer. `devframe` must not depend on Vite, any `@vitejs/*` package, or hub-only concepts; it's the lowest-level layer in the monorepo. + +Full reference: [devfra.me/](https://devfra.me/). + +## When to use devframe + +All adapter factories share the shape `createXxx(devframeDef, options?)`. + +| Author goal | Factory | Entry | +|-------------|---------|-------| +| Standalone CLI for local use | `createCli(def, options?)` | `devframe/adapters/cli` | +| Run the dev server programmatically (any CLI framework) | `createDevServer(def, options?)` | `devframe/adapters/dev` | +| Mount a SPA in an existing Vite dev server | `createVitePlugin(def, options?)` | `devframe/adapters/vite` | +| Self-contained static deploy with baked data | `createBuild(def, options?)` | `devframe/adapters/build` | +| Integrate into Vite DevTools | `createPluginFromDevframe(def, options?)` | `@vitejs/devtools-kit/node` | +| Register dynamically at runtime | `createEmbedded(def, { ctx })` | `devframe/adapters/embedded` | +| Expose to coding agents (MCP) | `createMcpServer(def, options?)` | `devframe/adapters/mcp` *(experimental)* | + +The same `DevframeDefinition` runs under every adapter — pick based on deployment, not on what the tool does. + +## Minimum viable devframe + +```ts +import { defineDevframe, defineRpcFunction } from 'devframe' + +export default defineDevframe({ + id: 'my-inspector', + name: 'My Inspector', + icon: 'ph:magnifying-glass-duotone', + cli: { distDir: './client/dist' }, + setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'my-inspector:get-stats', + type: 'static', + handler: () => ({ count: 42 }), + })) + }, +}) +``` + +`setup(ctx)` registers RPC functions, shared state, diagnostics, and any other devframe-level wiring. It does **not** receive `docks` / `terminals` / `messages` / `commands` — those are hub features. When mounted into Vite DevTools via `createPluginFromDevframe(d)`, the kit auto-derives an iframe dock entry from `id` / `name` / `icon` / `basePath`; for richer hub-side behaviour (custom-render, terminals, palette commands) pass `options.setup` to `createPluginFromDevframe`. + +See `templates/counter-devframe.ts` for a runnable counter example, `templates/spa-devframe.ts` for an SPA-ready shape, and `templates/vite-client.ts` for the author's client entry. + +## Namespacing + +**Always prefix** RPC names, dock IDs, command IDs, shared-state keys, and agent tool IDs with the devframe `id`: + +```ts +'my-inspector:get-modules' // ✓ +'my-inspector:state' // ✓ +'get-modules' // ✗ — may collide with other devframes sharing the host +``` + +## DevToolsNodeContext at a glance + +`setup(ctx)` receives the framework-neutral server-side surface. Each host corresponds to a [docs](https://devfra.me/) page: + +| Host | Purpose | +|------|---------| +| `ctx.rpc` | Register RPC functions, broadcast, shared state, streaming channels | +| `ctx.views` | Serve static files via `hostStatic(base, distDir)` | +| `ctx.diagnostics` | Structured diagnostics host (logs-sdk) — register custom error codes | +| `ctx.agent` | Expose tools + resources to coding agents (experimental) | +| `ctx.host` | Runtime abstraction — `mountStatic`, `resolveOrigin`, `getStorageDir` | +| `ctx.mode` | `'dev'` or `'build'` — gate setup work per runtime | + +> Hub-only hosts (`ctx.docks`, `ctx.terminals`, `ctx.messages`, `ctx.commands`, `ctx.createJsonRenderer`) only exist when the devframe is mounted into Vite DevTools via `createPluginFromDevframe`. See the [`vite-devtools-kit` skill](../../skills/vite-devtools-kit) for those. + +## RPC contracts + +```ts +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +const getModules = defineRpcFunction({ + name: 'my-inspector:get-modules', + type: 'query', + jsonSerializable: true, + args: [v.object({ limit: v.number() })], + returns: v.array(v.object({ id: v.string(), size: v.number() })), + setup: ctx => ({ + handler: async ({ limit }) => loadModules().slice(0, limit), + }), +}) +``` + +| Type | Use when | Cached | Static dump | +|------|----------|--------|-------------| +| `'static'` | Data constant for a given input — dump at build time | Indefinitely | Automatic | +| `'query'` | Read that may change; optional `dump` for build adapters | Opt-in via `cacheable` | Manual | +| `'action'` | Server-state mutation | Never | Never | +| `'event'` | Fire-and-forget; no response | Never | Never | + +Add valibot schemas when the RPC is user-facing, when you want static dumps, or when you expose it to agents. Prefer a **single object arg** (`args: [v.object({ ... })]`) over positional args — property names self-document and agents rely on them. + +### `jsonSerializable` (wire + dump format) + +`jsonSerializable` declares the on-wire / on-disk shape contract: + +| Value | Encoder | Wire prefix | Round-trips | +|-------|---------|-------------|-------------| +| `false` (default) | `structured-clone-es` | `s:` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances | +| `true` (opt-in) | strict `JSON.stringify` | _(unprefixed)_ | JSON-only | + +Set `jsonSerializable: true` when your handler returns plain JSON shapes — the strict serializer **throws `DF0020`** synchronously on the offending call when it sees a value JSON cannot round-trip (Map/Set/Date/BigInt/class instance/`undefined`-in-array). Errors surface in dev next to the call that introduced them, not silently at build time. + +`agent: {...}` requires `jsonSerializable: true` (registration throws `DF0019` otherwise). MCP tools speak JSON — opting into the agent surface is also opting into JSON-only data. + +`ctx.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client. `ctx.rpc.invokeLocal(name, ...args)` calls a server function without going through transport (useful for cross-function composition). + +## Shared state + +```ts +const state = await ctx.rpc.sharedState.get('my-inspector:state', { + initialValue: { count: 0, items: [] as string[] }, +}) + +state.mutate((draft) => { + draft.count += 1 + draft.items.push('tick') +}) +``` + +- Values must be serializable — no functions, no circular refs. +- Mutations round-trip to all clients; the host tracks `syncIds` to avoid replay loops. +- Prefer shared state over ad-hoc RPC events for UI that must reappear after reconnect. + +## Streaming channels + +For chunk-style data flowing in either direction — LLM deltas, log tails, build progress, file uploads, mic / screen-share frames — use a streaming channel instead of inventing `action + delta/end` events. The same `channel` object handles both directions: + +```ts +const channel = ctx.rpc.streaming.create('my-inspector:tokens', { + replayWindow: 256, // server keeps last N chunks per stream id + closedStreamRetention: 30_000, // ms to hold finished streams for late subscribers +}) +``` + +### Server-to-client (the common case) + +```ts +// Server — typically inside an action handler that returns the stream id +const stream = channel.start({ id: 'optional-stream-id' }) +stream.write(token) // imperative +stream.error(err) // terminal failure +stream.close() // terminal success +stream.signal // AbortSignal — flips when consumers cancel or all subscribers drop +stream.writable // WritableStream for `pipeTo`-style consumption + +// Convenience — start + pipe in one call: +await channel.pipeFrom(sourceReadable, { id: 'optional' }) + +// Client +const reader = rpc.streaming.subscribe('my-inspector:tokens', streamId) +for await (const token of reader) renderToken(token) +// Or: reader.readable.pipeTo(domWritable) +reader.cancel() // server `stream.signal` aborts +``` + +### Client-to-server uploads + +The same channel exposes `openInbound()` for the server side of a client→server upload. Pair it with a normal action that returns the id: + +```ts +// Server +ctx.rpc.register(defineRpcFunction({ + name: 'my-inspector:upload-file', + type: 'action', + args: [v.object({ name: v.string() })], + returns: v.object({ uploadId: v.string() }), + handler: async ({ name }) => { + const reader = channel.openInbound() + ;(async () => { + for await (const chunk of reader) saveChunk(chunk) + })() + return { uploadId: reader.id } + }, +})) + +// Client +const { uploadId } = await rpc.call('my-inspector:upload-file', { name: 'foo' }) +const upload = rpc.streaming.upload('my-inspector:files', uploadId) +fileReadable.pipeTo(upload.writable, { signal: upload.signal }) +``` + +Client disconnect surfaces as `UploadDisconnected` to the server's `for await`. Server-side `reader.cancel()` broadcasts `upload-cancel` to the uploading session, flipping `upload.signal`. + +### Lifecycle + +| Event | Server | Client | +|-------|--------|--------| +| `stream.close()` / `stream.error(err)` | broadcasts `end` to subscribers | `for await` resolves / throws | +| `reader.cancel()` (last subscriber) | `stream.signal` aborts | reader marked cancelled | +| WS disconnects (last subscriber drops) | `stream.signal` aborts | reader auto-resubscribes after re-trust | + +Producers should always poll `stream.signal.aborted` and exit cooperatively. + +### Web / Node Streams interop + +Web Streams are the canonical surface. Node 17+ ships free converters: + +```ts +import { Readable, Writable } from 'node:stream' + +sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) +Readable.fromWeb(reader.readable).pipe(targetNodeWritable) +``` + +### When to use streaming vs events vs shared state + +| Use streaming for | Use `event`-typed RPC for | Use shared state for | +|-------------------|---------------------------|----------------------| +| Token / chunk feeds, uploads | Notifications without payload (`refresh`) | Long-lived UI state that survives reconnect | +| Per-call lifecycles with cancellation | Cross-cutting fire-and-forget signals | Diff-based sync between clients | +| Replay on reconnect | | | + +For chat-style UIs that combine both: keep the **conversation log** in shared state (survives reconnects), and use a streaming channel for **active responses**. The action that starts a response appends a placeholder to shared state; on producer close, commit the joined content back to shared state. Working example: [`devframe/examples/devframe-streaming-chat`](https://github.com/vitejs/devtools/tree/main/devframe/examples/devframe-streaming-chat). + +## Mounting into Vite DevTools + +A portable devframe definition is dropped into the Vite DevTools hub via `createPluginFromDevframe`: + +```ts +// vite.config.ts +import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' +import myInspector from './my-inspector' + +export default { + plugins: [ + createPluginFromDevframe(myInspector, { + // Optional kit-only setup — runs after the auto-derived dock entry. + setup(kitCtx) { + kitCtx.commands.register({ + id: 'my-inspector:clear-cache', + title: 'Clear Cache', + handler: () => { /* ... */ }, + }) + }, + }), + ], +} +``` + +The kit auto-derives an iframe dock entry from `id` / `name` / `icon` / `basePath`. For dock variations (custom-render, launcher, action, json-render), terminals, palette commands, and toasts, use the `options.setup` hook — those APIs live on the kit-augmented context, not on the devframe-level `setup`. See the [`vite-devtools-kit` skill](../../skills/vite-devtools-kit) for the hub-side reference. + +## When clauses + +Gate kit-side dock / command visibility with VS Code-style expressions. The runtime + types ship bundled from `devframe/utils/when` — no separate install. The consumers (`when` field on docks and commands) live in the kit: + +```ts +when: 'clientType == embedded' +when: 'dockOpen && !paletteOpen' +when: 'my-inspector.ready && count >= 10' +``` + +Built-in context: `clientType` (`'embedded' | 'standalone'`), `dockOpen`, `paletteOpen`, `dockSelectedId`. Plugins can add namespaced keys (`.` or `:` separators). Both the types (`WhenExpression`) and runtime (`evaluateWhen`, `resolveContextValue`) come from `devframe/utils/when`. + +## Agent-native surface (experimental) + +Opt an RPC function into the agent surface with an `agent` field — default-deny otherwise. Agent-exposed functions **must declare `jsonSerializable: true`** (registration throws `DF0019` otherwise): + +```ts +defineRpcFunction({ + name: 'my-inspector:get-stats', + type: 'query', + jsonSerializable: true, + args: [v.object({ limit: v.number() })], + returns: v.object({ count: v.number() }), + agent: { + description: 'Return the top-N module stats. Safe to call freely.', + // safety inferred from type: 'query' → 'read' + }, + setup: () => ({ handler: async ({ limit }) => ({ count: limit }) }), +}) +``` + +Or register tools / resources directly: + +```ts +ctx.agent.registerTool({ + id: 'my-inspector:summarize', + description: 'Plain-text summary of the current scan.', + safety: 'read', + handler: async () => ({ markdown: buildSummary() }), +}) + +ctx.agent.registerResource({ + id: 'current-scan', + name: 'Current scan', + mimeType: 'text/markdown', + read: () => ({ text: renderMarkdown(currentScan) }), +}) +``` + +Expose via MCP: + +```ts +import { createMcpServer } from 'devframe/adapters/mcp' + +await createMcpServer(devframe, { transport: 'stdio' }) +``` + +`@modelcontextprotocol/sdk` is a peer dependency. The CLI adapter also exposes `my-devframe mcp` — route host logs to stderr (stdout is the MCP transport). Safety classifications (`'read' | 'action' | 'destructive'`) drive MCP hint annotations that agent clients use to prompt for confirmation. + +## Author SPA + +Authors bring their own SPA (any framework or plain HTML). Client entry: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() +// await rpc.ensureTrusted() // WS mode only — blocks until server accepts + +const data = await rpc.call('my-inspector:get-stats', { limit: 10 }) +``` + +`connectDevframe` auto-detects the backend via `/.devtools/.connection.json`: + +- **websocket** (dev mode) — full read/write, requires auth handshake. Listen for token updates on the `vite-devtools-auth` BroadcastChannel. +- **static** (build / spa output) — read-only, resolves calls from the baked RPC dump. + +Use `rpc.sharedState.get(key)` for observable state, `rpc.client.register(defineRpcFunction(...))` to receive server broadcasts, and `rpc.callOptional(...)` when a missing handler should resolve to `undefined` instead of throwing. + +## Build dumps + +`createBuild` bakes `static` function results automatically. For `query` functions, supply `dump` (or `snapshot: true` for the no-args sugar): + +```ts +defineRpcFunction({ + name: 'my-inspector:get-session', + type: 'query', + setup: () => ({ + handler: async (id: string) => loadSession(id), + dump: { + inputs: [['session-a'], ['session-b']], + fallback: { id: 'unknown', data: null }, + }, + }), +}) +``` + +At runtime, static clients look up the argument hash in the dump; misses resolve to `fallback` (or throw if absent). + +## CLI adapter subcommands + +`createCli(devframe).parse()` gives the tool four subcommands out of the box: + +| Subcommand | Action | +|------------|--------| +| *(default)* | Dev server on port 9999 (or `--port`) — WebSocket RPC, `cli.distDir` served at `/.devtools/` | +| `build` | Static snapshot → `./dist-static/` (configurable via `--out-dir`) | +| `spa` | Deployable SPA → `./dist-spa/` | +| `mcp` | stdio MCP server (experimental) | + +**Bring your own CLI framework?** `createCli` is just a cac wrapper around three peer factories — `createDevServer` (`devframe/adapters/dev`), `createBuild` (`devframe/adapters/build`), and `createMcpServer` (`devframe/adapters/mcp`). Use them directly with commander/yargs/oclif when `createCli`'s baked-in command structure doesn't fit. `createDevServer` returns a `StartedServer` handle (`origin`, `port`, `app`, `wss`, `close()`) so you can wire SIGINT / hot-reload teardown into the surrounding program. `parseCliFlags(schema, raw)` and `defineCliFlags(...)` (both from `devframe/adapters/cli`) validate an arbitrary flag bag against a `CliFlagsSchema` — typed flags aren't tied to cac. + +## Bundled utilities + +Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are bundled — never add the underlying packages to a devtool's own `package.json`: + +| Import | Wraps | Use for | +|--------|-------|---------| +| `colors` from `devframe/utils/colors` | `ansis` | Terminal ANSI colors (`c.red`, `c.green`, tagged templates) | +| `open` from `devframe/utils/open` | `open` | Open URLs / files in the OS default handler | +| `launchEditor` from `devframe/utils/launch-editor` | `launch-editor` | Open `file:line:column` in the user's editor (optional `editor` arg) | +| `hash` from `devframe/utils/hash` | `ohash` | Stable structural hash — cache keys, dedup | +| `structuredClone{Serialize,Deserialize,Stringify,Parse}` from `devframe/utils/structured-clone` | `structured-clone-es` | JSON-safe round-trip of `Map` / `Set` / `Date` / `BigInt` / cycles | +| `humanId` from `devframe/utils/human-id` | `human-id` | Human-readable IDs (`bright-orange-tiger`) | +| `nanoid` from `devframe/utils/nanoid` | (vendored) | URL-safe random IDs | +| `promiseWithResolver` from `devframe/utils/promise` | — | Externally-controlled `Promise` | +| `createEventEmitter` from `devframe/utils/events` | — | Typed event bus | +| `createSharedState` from `devframe/utils/shared-state` | (immer internal) | Immutable state container (see `ctx.rpc.sharedState`) | +| `createStreamSink` / `createStreamReader` from `devframe/utils/streaming-channel` | — | Low-level streaming primitives | +| `evaluateWhen` / `WhenExpression` from `devframe/utils/when` | `whenexpr` | When-clause expressions | + +For "open file in editor" + "reveal in finder", prefer the prebuilt `openHelpers` RPC recipe — it wires the two utilities into named RPC functions ready to register. + +## Testing + +- Unit-test host classes with fake contexts. +- Run `templates/counter-devframe.ts` under each adapter for integration coverage. +- Snapshot the build-static RPC dump (`/.devtools/.rpc-dump/index.json`) to catch accidental drift in `static` function outputs. + +## Further reading + +Devframe-level pages (one-tool, portable surface): + +- [Devframe Definition](https://devfra.me/devframe-definition) — fields, runtime flags, multi-adapter wiring +- [Adapters](https://devfra.me/adapters) — full reference for all deployment adapters +- [RPC](https://devfra.me/rpc) — types, schema, broadcasts, dumps +- [Shared State](https://devfra.me/shared-state) — patches, events, client-side mutation +- [Streaming](https://devfra.me/streaming) — chunked feeds, uploads, replay, Web/Node Streams interop +- [When Clauses](https://devfra.me/when-clauses) — syntax, context, type-safe wrappers +- [Structured Diagnostics](https://devfra.me/diagnostics) — coded errors via `ctx.diagnostics`, register custom codes +- [Utilities](https://devfra.me/utilities) — bundled `devframe/utils/*` helpers (colors, hash, launchEditor, structured-clone, …) +- [Client](https://devfra.me/client) — auth handshake, modes, discovery +- [Agent-Native](https://devfra.me/agent-native) — agent field, tools/resources, MCP + Claude Desktop + +Hub-only surfaces (Vite DevTools Kit — only available when mounted into the hub): + +- [Vite DevTools Kit overview](https://devtools.vite.dev/kit/) +- [Dock System](https://devtools.vite.dev/kit/dock-system) — every entry type + remote docks +- [Commands](https://devtools.vite.dev/kit/commands) — palette, keybindings, sub-commands +- [Messages & Notifications](https://devtools.vite.dev/kit/messages) — entry fields, positional hints +- [Terminals](https://devtools.vite.dev/kit/terminals) — child processes, external sessions diff --git a/skills/devframe/templates/counter-devframe.ts b/skills/devframe/templates/counter-devframe.ts new file mode 100644 index 0000000..0962cf8 --- /dev/null +++ b/skills/devframe/templates/counter-devframe.ts @@ -0,0 +1,24 @@ +// Smallest possible devframe. The dock entry is auto-derived from +// `id` / `name` / `icon` when this definition is mounted into Vite +// DevTools via `createPluginFromDevframe(devframe)`. +import { defineDevframe, defineRpcFunction } from 'devframe' + +let counter = 0 + +export default defineDevframe({ + id: 'counter', + name: 'Counter', + icon: 'ph:counter-duotone', + setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'counter:get', + type: 'static', + handler: () => ({ count: counter }), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'counter:bump', + type: 'action', + handler: () => ({ count: ++counter }), + })) + }, +}) diff --git a/skills/devframe/templates/spa-devframe.ts b/skills/devframe/templates/spa-devframe.ts new file mode 100644 index 0000000..a5a7a85 --- /dev/null +++ b/skills/devframe/templates/spa-devframe.ts @@ -0,0 +1,28 @@ +// Devframe with setupBrowser + SPA query-loader — deployable as a static site. +// When mounted into Vite DevTools via `createPluginFromDevframe`, the kit +// auto-derives an iframe dock from `id` / `name` / `icon`. +import { defineDevframe, defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +export default defineDevframe({ + id: 'my-inspector', + name: 'My Inspector', + icon: 'ph:magnifying-glass-duotone', + setup(ctx) { + ctx.rpc.register(defineRpcFunction({ + name: 'my-inspector:analyze', + type: 'query', + args: [v.object({ url: v.string() })], + handler: async ({ url }: { url: string }) => { + // Server-side implementation (used by CLI/build adapters). + return { url, verdict: 'ok' as const } + }, + })) + }, + setupBrowser() { + // Browser-side implementation — used by the SPA adapter so the + // deployed static site can answer RPC without a server. + // (Wire up an in-browser handler here once the SPA adapter lands.) + }, + spa: { loader: 'query' }, +}) diff --git a/skills/devframe/templates/vite-client.ts b/skills/devframe/templates/vite-client.ts new file mode 100644 index 0000000..d443ec7 --- /dev/null +++ b/skills/devframe/templates/vite-client.ts @@ -0,0 +1,11 @@ +// Minimum SPA client that talks to a devframe-hosted RPC. +import { connectDevframe } from 'devframe/client' + +async function main() { + const rpc = await connectDevframe() + // The method names below are just examples — replace with your own. + const data = await rpc.call('my-inspector:getStats' as any) + document.getElementById('root')!.textContent = JSON.stringify(data) +} + +main().catch(console.error) diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index e1c8f2c..0000000 --- a/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const one = 1 -export const two = 2 diff --git a/test/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/test/__snapshots__/tsnapi/devframe/index.snapshot.d.ts deleted file mode 100644 index 5eb7e60..0000000 --- a/test/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `devframe` - */ -// #region Variables -export declare const one: number; -export declare const two: number; -// #endregion \ No newline at end of file diff --git a/test/api-snapshot.test.ts b/test/api-snapshot.test.ts deleted file mode 100644 index af7d676..0000000 --- a/test/api-snapshot.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { guardStaleBuild } from 'tsdown-stale-guard' -import { snapshotApiPerEntry } from 'tsnapi/vitest' -import { beforeAll, describe } from 'vitest' - -const root = fileURLToPath(new URL('..', import.meta.url)) - -describe('api snapshot', () => { - beforeAll(async () => { - await guardStaleBuild({ root }) - }) - - snapshotApiPerEntry(root) -}) diff --git a/tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.d.ts new file mode 100644 index 0000000..ddb75f8 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.d.ts @@ -0,0 +1,20 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/nuxt` + */ +// #region Interfaces +export interface DevframeNuxtModuleOptions { + baseURL?: string; + skipAppDefaults?: boolean; + devframe?: DevframeDefinition; + devMiddleware?: boolean | { + port?: number; + host?: string; + flags?: Record; + }; +} +// #endregion + +// #region Default Export +declare const _default: NuxtModule; +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.js new file mode 100644 index 0000000..e16a081 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/nuxt/index.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/nuxt` + */ +// #region Default Export +var _default +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.d.ts new file mode 100644 index 0000000..77caac7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.d.ts @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/nuxt/runtime/plugin.client` + */ +// #region Default Export +declare const _default: any; +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.js new file mode 100644 index 0000000..c9b82d2 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/nuxt/runtime/plugin.client.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/nuxt/runtime/plugin.client` + */ +// #region Default Export +var _default +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts new file mode 100644 index 0000000..621f03a --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts @@ -0,0 +1,15 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/build` + */ +// #region Interfaces +export interface CreateBuildOptions { + outDir?: string; + base?: string; + distDir?: string; + pretty?: boolean; +} +// #endregion + +// #region Functions +export declare function createBuild(_: DevframeDefinition, _?: CreateBuildOptions): Promise; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js new file mode 100644 index 0000000..c653d57 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/build` + */ +// #region Functions +export async function createBuild(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts new file mode 100644 index 0000000..e7329cf --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts @@ -0,0 +1,29 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/cli` + */ +// #region Interfaces +export interface CliHandle { + cli: CAC; + parse: (_?: string[]) => Promise; +} +export interface CreateCliOptions { + defaultPort?: number; + configureCli?: (_: CAC) => void; + onReady?: (_: { + origin: string; + port: number; + app: App; + }) => void | Promise; +} +// #endregion + +// #region Functions +export declare function createCli(_: DevframeDefinition, _?: CreateCliOptions): CliHandle; +// #endregion + +// #region Other +export { CliFlagsSchema } +export { defineCliFlags } +export { InferCliFlags } +export { parseCliFlags } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js new file mode 100644 index 0000000..e2e54c7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/cli` + */ +// #region Functions +export function createCli(_, _) {} +export function defineCliFlags(_) {} +export function parseCliFlags(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts new file mode 100644 index 0000000..1678628 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -0,0 +1,28 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/dev` + */ +// #region Interfaces +export interface CreateDevServerOptions { + host?: string; + port?: number; + flags?: Record; + distDir?: string; + basePath?: string; + app?: App; + openBrowser?: boolean | string; + onReady?: (_: { + origin: string; + port: number; + app: App; + }) => void | Promise; +} +export interface ResolveDevServerPortOptions { + host?: string; + defaultPort?: number; +} +// #endregion + +// #region Functions +export declare function createDevServer(_: DevframeDefinition, _?: CreateDevServerOptions): Promise; +export declare function resolveDevServerPort(_: DevframeDefinition, _?: ResolveDevServerPortOptions): Promise; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js new file mode 100644 index 0000000..6d6b494 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/dev` + */ +// #region Other +export { createDevServer } +export { resolveDevServerPort } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts new file mode 100644 index 0000000..256156c --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.d.ts @@ -0,0 +1,12 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/embedded` + */ +// #region Interfaces +export interface CreateEmbeddedOptions { + ctx: DevToolsNodeContext; +} +// #endregion + +// #region Functions +export declare function createEmbedded(_: DevframeDefinition, _: CreateEmbeddedOptions): Promise; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.js new file mode 100644 index 0000000..1bfb895 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/embedded.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/embedded` + */ +// #region Functions +export async function createEmbedded(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts new file mode 100644 index 0000000..ce7dfb3 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -0,0 +1,21 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` + */ +// #region Interfaces +export interface CreateMcpServerOptions { + transport?: 'stdio'; + exposeSharedState?: boolean | ((_: string) => boolean); + serverName?: string; + serverVersion?: string; + onReady?: (_: { + transport: 'stdio'; + }) => void; +} +export interface McpServerHandle { + stop: () => Promise; +} +// #endregion + +// #region Functions +export declare function createMcpServer(_: DevframeDefinition, _?: CreateMcpServerOptions): Promise; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js new file mode 100644 index 0000000..fbf2285 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` + */ +// #region Functions +export async function createMcpServer(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.d.ts new file mode 100644 index 0000000..2b01119 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.d.ts @@ -0,0 +1,14 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/spa` + */ +// #region Interfaces +export interface CreateSpaOptions { + outDir?: string; + base?: string; + pretty?: boolean; +} +// #endregion + +// #region Functions +export declare function createSpa(_: DevtoolDefinition, _?: CreateSpaOptions): Promise; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.js new file mode 100644 index 0000000..102d91f --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/spa.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/spa` + */ +// #region Functions +export async function createSpa(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.d.ts new file mode 100644 index 0000000..95cd043 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.d.ts @@ -0,0 +1,30 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/vite` + */ +// #region Interfaces +export interface CreateVitePluginOptions { + base?: string; + devMiddleware?: boolean | { + port?: number; + host?: string; + flags?: Record; + }; +} +export interface DevframeVitePlugin { + name: string; + apply: 'serve'; + configureServer: (_: { + middlewares: { + use: (_: string, _: any) => void; + }; + httpServer?: { + once: (_: 'close', _: () => void) => void; + } | null; + }) => void | Promise; + closeBundle?: () => void | Promise; +} +// #endregion + +// #region Functions +export declare function createVitePlugin(_: DevframeDefinition, _?: CreateVitePluginOptions): DevframeVitePlugin; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.js new file mode 100644 index 0000000..75bc009 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/adapters/vite.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/vite` + */ +// #region Functions +export function createVitePlugin(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts new file mode 100644 index 0000000..ddb319c --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -0,0 +1,67 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/client` + */ +// #region Interfaces +export interface DevToolsRpcClient { + events: EventEmitter; + readonly isTrusted: boolean | null; + readonly connectionMeta: ConnectionMeta; + ensureTrusted: (_?: number) => Promise; + requestTrust: () => Promise; + requestTrustWithToken: (_: string) => Promise; + call: DevToolsRpcClientCall; + callEvent: DevToolsRpcClientCallEvent; + callOptional: DevToolsRpcClientCallOptional; + client: DevToolsClientRpcHost; + sharedState: RpcSharedStateHost; + streaming: RpcStreamingClientHost; + cacheManager: RpcCacheManager; +} +export interface DevToolsRpcClientMode { + readonly isTrusted: boolean; + ensureTrusted: DevToolsRpcClient['ensureTrusted']; + requestTrust: DevToolsRpcClient['requestTrust']; + requestTrustWithToken: DevToolsRpcClient['requestTrustWithToken']; + call: DevToolsRpcClient['call']; + callEvent: DevToolsRpcClient['callEvent']; + callOptional: DevToolsRpcClient['callOptional']; +} +export interface DevToolsRpcClientOptions { + connectionMeta?: ConnectionMeta; + baseURL?: string | string[]; + authToken?: string; + wsOptions?: Partial; + rpcOptions?: Partial>; + cacheOptions?: boolean | Partial; +} +export interface DevToolsRpcContext { + readonly rpc: DevToolsRpcClient; +} +export interface RpcClientEvents { + 'rpc:is-trusted:updated': (_: boolean) => void; +} +export interface RpcStreamingClientHost { + subscribe: (_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader; + upload: (_: string, _: string) => StreamSink; +} +export interface StreamingSubscribeOptions { + highWaterMark?: number; +} +// #endregion + +// #region Types +export type DevToolsClientRpcHost = RpcFunctionsCollector; +export type DevToolsRpcClientCall = BirpcReturn['$call']; +export type DevToolsRpcClientCallEvent = BirpcReturn['$callEvent']; +export type DevToolsRpcClientCallOptional = BirpcReturn['$callOptional']; +// #endregion + +// #region Functions +export declare function connectDevtool(..._: Parameters): ReturnType; +export declare function createRpcStreamingClientHost(_: DevToolsRpcClient): RpcStreamingClientHost; +export declare function getDevToolsRpcClient(_?: DevToolsRpcClientOptions): Promise; +// #endregion + +// #region Variables +export declare const connectDevframe: typeof getDevToolsRpcClient; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js new file mode 100644 index 0000000..7f4b22d --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -0,0 +1,12 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/client` + */ +// #region Functions +export function connectDevtool(..._) {} +export function createRpcStreamingClientHost(_) {} +export async function getDevToolsRpcClient(_) {} +// #endregion + +// #region Variables +export var connectDevframe /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts new file mode 100644 index 0000000..089b973 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -0,0 +1,14 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/constants` + */ +// #region Variables +export declare const DEVTOOLS_CONNECTION_META_FILENAME: string; +export declare const DEVTOOLS_DIRNAME: string; +export declare const DEVTOOLS_DOCK_IMPORTS_FILENAME: string; +export declare const DEVTOOLS_DOCK_IMPORTS_VIRTUAL_ID: string; +export declare const DEVTOOLS_MOUNT_PATH: string; +export declare const DEVTOOLS_MOUNT_PATH_NO_TRAILING_SLASH: string; +export declare const DEVTOOLS_RPC_DUMP_DIRNAME: string; +export declare const DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME: string; +export declare const REMOTE_CONNECTION_KEY: string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js new file mode 100644 index 0000000..bc40e7f --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -0,0 +1,14 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/constants` + */ +// #region Variables +export var DEVTOOLS_CONNECTION_META_FILENAME /* const */ +export var DEVTOOLS_DIRNAME /* const */ +export var DEVTOOLS_DOCK_IMPORTS_FILENAME /* const */ +export var DEVTOOLS_DOCK_IMPORTS_VIRTUAL_ID /* const */ +export var DEVTOOLS_MOUNT_PATH /* const */ +export var DEVTOOLS_MOUNT_PATH_NO_TRAILING_SLASH /* const */ +export var DEVTOOLS_RPC_DUMP_DIRNAME /* const */ +export var DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME /* const */ +export var REMOTE_CONNECTION_KEY /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts new file mode 100644 index 0000000..d41cd11 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -0,0 +1,61 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe` + */ +// #region Variables +export declare const defineRpcFunction: (definition: RpcFunctionDefinition) => RpcFunctionDefinition; +// #endregion + +// #region Other +export { AgentHandle } +export { AgentManifest } +export { AgentResource } +export { AgentResourceContent } +export { AgentResourceInput } +export { AgentTool } +export { AgentToolInput } +export { ConnectionMeta } +export { defineDevframe } +export { defineDevtool } +export { DevframeBrowserContext } +export { DevframeCliOptions } +export { DevframeDefinition } +export { DevframeDeploymentKind } +export { DevframeRuntime } +export { DevframeSetupInfo } +export { DevframeSpaOptions } +export { DevtoolBrowserContext } +export { DevtoolCliOptions } +export { DevtoolDefinition } +export { DevtoolDeploymentKind } +export { DevtoolRuntime } +export { DevToolsAgentHost } +export { DevToolsAgentHostEvents } +export { DevToolsCapabilities } +export { DevToolsDiagnosticsDefinition } +export { DevToolsDiagnosticsHost } +export { DevToolsDiagnosticsLogger } +export { DevtoolSetupInfo } +export { DevToolsHost } +export { DevToolsNodeContext } +export { DevToolsNodeRpcSession } +export { DevToolsNodeRpcSessionMeta } +export { DevtoolSpaOptions } +export { DevToolsRpcClientFunctions } +export { DevToolsRpcServerFunctions } +export { DevToolsRpcSharedStates } +export { DevToolsViewHost } +export { EntriesToObject } +export { EventEmitter } +export { EventsMap } +export { EventUnsubscribe } +export { PartialWithoutId } +export { RpcBroadcastOptions } +export { RpcFunctionAgentOptions } +export { RpcFunctionsHost } +export { RpcSharedStateGetOptions } +export { RpcSharedStateHost } +export { RpcStreamingChannel } +export { RpcStreamingChannelOptions } +export { RpcStreamingHost } +export { Thenable } +// #endregion \ No newline at end of file diff --git a/test/__snapshots__/tsnapi/devframe/index.snapshot.js b/tests/__snapshots__/tsnapi/devframe/index.snapshot.js similarity index 65% rename from test/__snapshots__/tsnapi/devframe/index.snapshot.js rename to tests/__snapshots__/tsnapi/devframe/index.snapshot.js index 55c3a23..2c37cb4 100644 --- a/test/__snapshots__/tsnapi/devframe/index.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.js @@ -2,6 +2,5 @@ * Generated by tsnapi — public API snapshot of `devframe` */ // #region Variables -export var one /* const */ -export var two /* const */ +export var defineRpcFunction /* const */ // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts new file mode 100644 index 0000000..516f87f --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -0,0 +1,125 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/node` + */ +// #region Interfaces +export interface CreateH3DevToolsHostOptions { + app?: unknown; + origin: string; + mount?: (_: string, _: string) => void | Promise; + appName: string; + workspaceRoot?: string; +} +export interface CreateHostContextOptions { + cwd: string; + workspaceRoot?: string; + mode: 'dev' | 'build'; + host: DevToolsHost; + builtinRpcDeclarations?: readonly RpcFunctionDefinitionAny[]; +} +export interface CreateStorageOptions { + filepath: string; + initialValue: T; + mergeInitialValue?: false | ((_: T, _: T) => T); + debounce?: number; +} +export interface StaticRpcDumpCollection { + manifest: StaticRpcDumpManifest; + files: Record; +} +export interface StaticRpcDumpFile { + serialization: StaticRpcDumpSerialization; + fnName: string; + data: unknown; +} +export interface StaticRpcDumpManifestQueryEntry { + type: 'query'; + records: Record; + fallback?: string; + serialization?: StaticRpcDumpSerialization; +} +export interface StaticRpcDumpManifestStaticEntry { + type: 'static'; + path: string; + serialization?: StaticRpcDumpSerialization; +} +// #endregion + +// #region Types +export type StaticRpcDumpManifest = Record; +export type StaticRpcDumpManifestValue = StaticRpcDumpManifestStaticEntry | StaticRpcDumpManifestQueryEntry | any; +export type StaticRpcDumpSerialization = 'json' | 'structured-clone'; +// #endregion + +// #region Classes +export declare class DevToolsAgentHost implements DevToolsAgentHost$1 { + readonly context: DevToolsNodeContext; + readonly events: EventEmitter; + private readonly tools; + private readonly resources; + private _rpcUnsubscribe; + constructor(_: DevToolsNodeContext); + registerTool(_: AgentToolInput): AgentHandle; + unregisterTool(_: string): boolean; + registerResource(_: AgentResourceInput): AgentHandle; + unregisterResource(_: string): boolean; + list(): AgentManifest; + getTool(_: string): AgentTool | undefined; + getResource(_: string): AgentResource | undefined; + invoke(_: string, _: unknown): Promise; + read(_: string): Promise; + _dispose(): void; + private _validateToolId; + private _projectTool; + private _collectRpcTools; + private _findRpcDefinition; + private _coercePositionalArgs; +} +export declare class DevToolsDiagnosticsHost implements DevToolsDiagnosticsHost$1 { + readonly context: DevToolsNodeContext; + private _definitions; + private _logger; + readonly defineDiagnostics: typeof defineDiagnostics; + readonly createLogger: typeof createLogger; + constructor(_: DevToolsNodeContext, _?: unknown[]); + get logger(): DevToolsDiagnosticsLogger; + register(_: unknown): void; + private _rebuild; +} +export declare class DevToolsViewHost implements DevToolsViewHost$1 { + readonly context: DevToolsNodeContext; + buildStaticDirs: { + baseUrl: string; + distDir: string; + }[]; + constructor(_: DevToolsNodeContext); + hostStatic(_: string, _: string): void; +} +export declare class RpcFunctionsHost extends RpcFunctionsCollectorBase implements RpcFunctionsHost$1 { + _rpcGroup: BirpcGroup; + _asyncStorage: AsyncLocalStorage; + constructor(_: DevToolsNodeContext); + sharedState: RpcSharedStateHost; + streaming: RpcStreamingHost; + _emitSessionDisconnected(_: DevToolsNodeRpcSessionMeta): void; + invokeLocal>(_: T, ..._: Args): Promise>>; + broadcast>(_: RpcBroadcastOptions): Promise; + getCurrentRpcSession(): DevToolsNodeRpcSession | undefined; +} +// #endregion + +// #region Functions +export declare function collectStaticRpcDump(_: Iterable, _: any): Promise; +export declare function createH3DevToolsHost(_: CreateH3DevToolsHostOptions): DevToolsHost; +export declare function createHostContext(_: CreateHostContextOptions): Promise; +export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost$1): RpcSharedStateHost; +export declare function createRpcStreamingServerHost(_: RpcFunctionsHost$1): RpcStreamingHost; +export declare function createStorage(_: CreateStorageOptions): SharedState; +export declare function isObject(_: unknown): value is Record; +export declare function normalizeHttpServerUrl(_: string, _: number | string): string; +// #endregion + +// #region Other +export { StartedServer } +export { startHttpAndWs } +export { StartHttpAndWsOptions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js new file mode 100644 index 0000000..6c132db --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -0,0 +1,21 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/node` + */ +// #region Functions +export function isObject(_) {} +export function normalizeHttpServerUrl(_, _) {} +// #endregion + +// #region Other +export { collectStaticRpcDump } +export { createH3DevToolsHost } +export { createHostContext } +export { createRpcSharedStateServerHost } +export { createRpcStreamingServerHost } +export { createStorage } +export { DevToolsAgentHost } +export { DevToolsDiagnosticsHost } +export { DevToolsViewHost } +export { RpcFunctionsHost } +export { startHttpAndWs } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts new file mode 100644 index 0000000..b618952 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts @@ -0,0 +1,27 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/node/auth` + */ +// #region Interfaces +export interface PendingAuthRequest { + clientAuthToken: string; + session: DevToolsNodeRpcSession; + ua: string; + origin: string; + resolve: (_: { + isTrusted: boolean; + }) => void; + abortController: AbortController; + timeout: ReturnType; +} +// #endregion + +// #region Functions +export declare function abortPendingAuth(): void; +export declare function consumeTempAuthToken(_: string, _: SharedState): string | null; +export declare function getPendingAuth(): PendingAuthRequest | null; +export declare function getTempAuthToken(): string; +export declare function refreshTempAuthToken(): string; +export declare function revokeActiveConnectionsForToken(_: DevToolsNodeContext, _: string): Promise; +export declare function revokeAuthToken(_: DevToolsNodeContext, _: SharedState, _: string): Promise; +export declare function setPendingAuth(_: PendingAuthRequest | null): void; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js new file mode 100644 index 0000000..fca4b00 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js @@ -0,0 +1,16 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/node/auth` + */ +// #region Functions +export function abortPendingAuth() {} +export function consumeTempAuthToken(_, _) {} +export function getPendingAuth() {} +export function getTempAuthToken() {} +export function refreshTempAuthToken() {} +export function setPendingAuth(_) {} +// #endregion + +// #region Other +export { revokeActiveConnectionsForToken } +export { revokeAuthToken } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.d.ts new file mode 100644 index 0000000..574165c --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.d.ts @@ -0,0 +1,15 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/node/internal` + */ +// #region Functions +export declare function normalizeBasePath(_: string): string; +export declare function resolveBasePath(_: DevframeDefinition, _: DevframeDeploymentKind): string; +// #endregion + +// #region Other +export { DevToolsInternalContext } +export { getInternalContext } +export { InternalAnonymousAuthStorage } +export { internalContextMap } +export { RemoteTokenRecord } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.js new file mode 100644 index 0000000..235aba6 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/node/internal.snapshot.js @@ -0,0 +1,15 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/node/internal` + */ +// #region Functions +export function getInternalContext(_) {} +// #endregion + +// #region Variables +export var internalContextMap /* const */ +// #endregion + +// #region Other +export { normalizeBasePath } +export { resolveBasePath } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts new file mode 100644 index 0000000..e97fdc7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts @@ -0,0 +1,64 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/recipes/open-helpers` + */ +// #region Variables +export declare const openHelpers: readonly [{ + name: "devframe:open-in-editor"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [v.StringSchema]; + returns: v.VoidSchema; + jsonSerializable?: boolean; + agent?: RpcFunctionAgentOptions; + setup?: ((context: undefined) => Thenable>) | undefined; + handler?: ((args_0: string) => void) | undefined; + dump?: RpcDump<[string], void, undefined> | undefined; + snapshot?: boolean; + __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __promise?: Thenable> | undefined; +}, { + name: "devframe:open-in-finder"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [v.StringSchema]; + returns: v.VoidSchema; + jsonSerializable?: boolean; + agent?: RpcFunctionAgentOptions; + setup?: ((context: undefined) => Thenable>) | undefined; + handler?: ((args_0: string) => void) | undefined; + dump?: RpcDump<[string], void, undefined> | undefined; + snapshot?: boolean; + __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __promise?: Thenable> | undefined; +}]; +export declare const openInEditor: { + name: "devframe:open-in-editor"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [v.StringSchema]; + returns: v.VoidSchema; + jsonSerializable?: boolean; + agent?: RpcFunctionAgentOptions; + setup?: ((context: undefined) => Thenable>) | undefined; + handler?: ((args_0: string) => void) | undefined; + dump?: RpcDump<[string], void, undefined> | undefined; + snapshot?: boolean; + __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __promise?: Thenable> | undefined; +}; +export declare const openInFinder: { + name: "devframe:open-in-finder"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [v.StringSchema]; + returns: v.VoidSchema; + jsonSerializable?: boolean; + agent?: RpcFunctionAgentOptions; + setup?: ((context: undefined) => Thenable>) | undefined; + handler?: ((args_0: string) => void) | undefined; + dump?: RpcDump<[string], void, undefined> | undefined; + snapshot?: boolean; + __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __promise?: Thenable> | undefined; +}; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.js b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.js new file mode 100644 index 0000000..58ef90e --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/recipes/open-helpers` + */ +// #region Variables +export var openHelpers /* const */ +export var openInEditor /* const */ +export var openInFinder /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts new file mode 100644 index 0000000..be2dfe7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts @@ -0,0 +1,43 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc` + */ +// #region Other +export { BirpcFn } +export { BirpcReturn } +export { createClientFromDump } +export { createDefineWrapperWithContext } +export { defineRpcFunction } +export { dumpFunctions } +export { EntriesToObject } +export { getDefinitionsWithDumps } +export { getRpcHandler } +export { getRpcResolvedSetupResult } +export { RpcArgsSchema } +export { RpcCacheManager } +export { RpcCacheOptions } +export { RpcDefinitionsFilter } +export { RpcDefinitionsToFunctions } +export { RpcDump } +export { RpcDumpClientOptions } +export { RpcDumpCollectionOptions } +export { RpcDumpDefinition } +export { RpcDumpGetter } +export { RpcDumpRecord } +export { RpcDumpStore } +export { RpcFunctionAgentOptions } +export { RpcFunctionDefinition } +export { RpcFunctionDefinitionAny } +export { RpcFunctionDefinitionAnyWithContext } +export { RpcFunctionDefinitionBase } +export { RpcFunctionDefinitionToFunction } +export { RpcFunctionsCollector } +export { RpcFunctionsCollectorBase } +export { RpcFunctionSetupResult } +export { RpcFunctionType } +export { RpcReturnSchema } +export { strictJsonStringify } +export { STRUCTURED_CLONE_PREFIX } +export { Thenable } +export { validateDefinition } +export { validateDefinitions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js new file mode 100644 index 0000000..76197e4 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js @@ -0,0 +1,18 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc` + */ +// #region Other +export { createClientFromDump } +export { createDefineWrapperWithContext } +export { defineRpcFunction } +export { dumpFunctions } +export { getDefinitionsWithDumps } +export { getRpcHandler } +export { getRpcResolvedSetupResult } +export { RpcCacheManager } +export { RpcFunctionsCollectorBase } +export { strictJsonStringify } +export { STRUCTURED_CLONE_PREFIX } +export { validateDefinition } +export { validateDefinitions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.d.ts new file mode 100644 index 0000000..9392ebf --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.d.ts @@ -0,0 +1,9 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/client` + */ +// #region Functions +export declare function createRpcClient, ClientFunctions extends object = Record>(_: ClientFunctions, _: { + channel: ChannelOptions; + rpcOptions?: Partial>; +}): BirpcReturn; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.js new file mode 100644 index 0000000..5c7baf7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/client.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/client` + */ +// #region Functions +export function createRpcClient(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.d.ts new file mode 100644 index 0000000..25baff7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.d.ts @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/server` + */ +// #region Functions +export declare function createRpcServer, ServerFunctions extends object = Record>(_: ServerFunctions, _?: { + rpcOptions?: EventOptions; +}): BirpcGroup; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.js new file mode 100644 index 0000000..eb6539b --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/server.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/server` + */ +// #region Functions +export function createRpcServer(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts new file mode 100644 index 0000000..22cde9f --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-client` + */ +// #region Other +export { createWsRpcChannel } +export { WsRpcChannelOptions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.js new file mode 100644 index 0000000..3f5244b --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-client` + */ +// #region Functions +export function createWsRpcChannel(_) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts new file mode 100644 index 0000000..fa54ae2 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-server` + */ +// #region Other +export { attachWsRpcTransport } +export { DevToolsNodeRpcSessionMeta } +export { WsRpcTransportOptions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js new file mode 100644 index 0000000..30adefc --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-server` + */ +// #region Functions +export function attachWsRpcTransport(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts new file mode 100644 index 0000000..1ced54b --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -0,0 +1,57 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/types` + */ +// #region Other +export { AgentHandle } +export { AgentManifest } +export { AgentResource } +export { AgentResourceContent } +export { AgentResourceInput } +export { AgentTool } +export { AgentToolInput } +export { ConnectionMeta } +export { defineDevframe } +export { defineDevtool } +export { DevframeBrowserContext } +export { DevframeCliOptions } +export { DevframeDefinition } +export { DevframeDeploymentKind } +export { DevframeRuntime } +export { DevframeSetupInfo } +export { DevframeSpaOptions } +export { DevtoolBrowserContext } +export { DevtoolCliOptions } +export { DevtoolDefinition } +export { DevtoolDeploymentKind } +export { DevtoolRuntime } +export { DevToolsAgentHost } +export { DevToolsAgentHostEvents } +export { DevToolsCapabilities } +export { DevToolsDiagnosticsDefinition } +export { DevToolsDiagnosticsHost } +export { DevToolsDiagnosticsLogger } +export { DevtoolSetupInfo } +export { DevToolsHost } +export { DevToolsNodeContext } +export { DevToolsNodeRpcSession } +export { DevToolsNodeRpcSessionMeta } +export { DevtoolSpaOptions } +export { DevToolsRpcClientFunctions } +export { DevToolsRpcServerFunctions } +export { DevToolsRpcSharedStates } +export { DevToolsViewHost } +export { EntriesToObject } +export { EventEmitter } +export { EventsMap } +export { EventUnsubscribe } +export { PartialWithoutId } +export { RpcBroadcastOptions } +export { RpcFunctionAgentOptions } +export { RpcFunctionsHost } +export { RpcSharedStateGetOptions } +export { RpcSharedStateHost } +export { RpcStreamingChannel } +export { RpcStreamingChannelOptions } +export { RpcStreamingHost } +export { Thenable } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.js b/tests/__snapshots__/tsnapi/devframe/types.snapshot.js new file mode 100644 index 0000000..9ac0142 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/types` + */ +// #region Functions +export function defineDevframe(_) {} +export function defineDevtool(_) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.d.ts new file mode 100644 index 0000000..1180db7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.d.ts @@ -0,0 +1,25 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/colors` + */ +// #region Interfaces +export interface ColorFn { + (_: unknown): string; + (_: TemplateStringsArray, ..._: unknown[]): string; +} +export interface Colors { + blue: ColorFn; + cyan: ColorFn; + gray: ColorFn; + green: ColorFn; + red: ColorFn; + yellow: ColorFn; + bold: ColorFn; + dim: ColorFn; + reset: ColorFn; + underline: ColorFn; +} +// #endregion + +// #region Variables +export declare const colors: Colors; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.js new file mode 100644 index 0000000..07944a1 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/colors.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/colors` + */ +// #region Other +export { colors } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.d.ts new file mode 100644 index 0000000..39b6d30 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/events` + */ +// #region Functions +export declare function createEventEmitter(): EventEmitter; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.js new file mode 100644 index 0000000..1d37eb9 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/events.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/events` + */ +// #region Functions +export function createEventEmitter() {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.d.ts new file mode 100644 index 0000000..d45126a --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/hash` + */ +// #region Functions +export declare function hash(_: unknown): string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.js new file mode 100644 index 0000000..beb6ff2 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/hash.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/hash` + */ +// #region Other +export { hash } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts new file mode 100644 index 0000000..3e2bb5e --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/human-id` + */ +// #region Functions +export declare function humanId(): string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js new file mode 100644 index 0000000..1035a68 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/human-id` + */ +// #region Other +export { humanId } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.d.ts new file mode 100644 index 0000000..0632e8c --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/launch-editor` + */ +// #region Functions +export declare function launchEditor(_: string, _?: string): void; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.js new file mode 100644 index 0000000..556e756 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/launch-editor.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/launch-editor` + */ +// #region Other +export { launchEditor } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.d.ts new file mode 100644 index 0000000..e7b5ab9 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/nanoid` + */ +// #region Functions +export declare function nanoid(_?: number): string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.js new file mode 100644 index 0000000..07eaeb8 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/nanoid.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/nanoid` + */ +// #region Functions +export function nanoid(_) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts new file mode 100644 index 0000000..b1a6822 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts @@ -0,0 +1,12 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/open` + */ +// #region Interfaces +export interface OpenOptions { + wait?: boolean; +} +// #endregion + +// #region Functions +export declare function open(_: string, _?: OpenOptions): Promise; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js new file mode 100644 index 0000000..f0c60dc --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/open` + */ +// #region Other +export { open } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.d.ts new file mode 100644 index 0000000..e5761af --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.d.ts @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/promise` + */ +// #region Functions +export declare function promiseWithResolver(): { + promise: Promise; + resolve: (_: T) => void; + reject: (_: Error) => void; +}; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.js new file mode 100644 index 0000000..5d9e6a3 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/promise.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/promise` + */ +// #region Functions +export function promiseWithResolver() {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts new file mode 100644 index 0000000..f7dc079 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts @@ -0,0 +1,14 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/serve-static` + */ +// #region Interfaces +export interface ServeStaticOptions { + indexNames?: string[]; + single?: boolean; +} +// #endregion + +// #region Functions +export declare function serveStaticHandler(_: string, _?: ServeStaticOptions): EventHandler; +export declare function serveStaticNodeMiddleware(_: string, _?: ServeStaticOptions): (_: IncomingMessage, _: ServerResponse, _?: (_?: Error) => void) => void; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.js new file mode 100644 index 0000000..6d015db --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/serve-static` + */ +// #region Functions +export function serveStaticHandler(_, _) {} +export function serveStaticNodeMiddleware(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.d.ts new file mode 100644 index 0000000..21cd1f0 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.d.ts @@ -0,0 +1,15 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/shared-state` + */ +// #region Other +export { createSharedState } +export { Immutable } +export { ImmutableArray } +export { ImmutableMap } +export { ImmutableObject } +export { ImmutableSet } +export { SharedState } +export { SharedStateEvents } +export { SharedStateOptions } +export { SharedStatePatch } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.js new file mode 100644 index 0000000..5ea863f --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/shared-state.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/shared-state` + */ +// #region Other +export { createSharedState } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts new file mode 100644 index 0000000..b733214 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.d.ts @@ -0,0 +1,14 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/streaming-channel` + */ +// #region Other +export { BufferedChunk } +export { createStreamReader } +export { CreateStreamReaderOptions } +export { createStreamSink } +export { CreateStreamSinkOptions } +export { StreamErrorPayload } +export { StreamReader } +export { StreamSink } +export { StreamSinkEvents } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js new file mode 100644 index 0000000..da8f482 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/streaming-channel.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/streaming-channel` + */ +// #region Functions +export function createStreamReader(_) {} +export function createStreamSink(_) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.d.ts new file mode 100644 index 0000000..ce662ba --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.d.ts @@ -0,0 +1,9 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/structured-clone` + */ +// #region Functions +export declare function structuredCloneDeserialize(_: unknown[]): T; +export declare function structuredCloneParse(_: string): T; +export declare function structuredCloneSerialize(_: unknown): unknown[]; +export declare function structuredCloneStringify(_: unknown): string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.js new file mode 100644 index 0000000..2afe2c3 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/structured-clone.snapshot.js @@ -0,0 +1,9 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/structured-clone` + */ +// #region Other +export { structuredCloneDeserialize } +export { structuredCloneParse } +export { structuredCloneSerialize } +export { structuredCloneStringify } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.d.ts new file mode 100644 index 0000000..734e08d --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.d.ts @@ -0,0 +1,24 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/when` + */ +// #region Interfaces +export interface EvaluateWhenOptions { + strict?: boolean; +} +export interface WhenContext { + clientType: 'embedded' | 'standalone'; + dockOpen: boolean; + paletteOpen: boolean; + dockSelectedId: string; + [key: string]: unknown; +} +// #endregion + +// #region Types +export type WhenExpression = WhenExpression$1; +// #endregion + +// #region Functions +export declare function evaluateWhen(_: E & WhenExpression$1, _: T, _?: EvaluateWhenOptions): boolean; +export declare function resolveContextValue>(_: string, _: T): unknown; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.js new file mode 100644 index 0000000..0c2b001 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/when.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/when` + */ +// #region Functions +export function evaluateWhen(_, _, _) {} +export function resolveContextValue(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/exports.test.ts b/tests/exports.test.ts new file mode 100644 index 0000000..4ffdea6 --- /dev/null +++ b/tests/exports.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describePackagesApiSnapshots } from 'tsnapi/vitest' + +describePackagesApiSnapshots({ + cwd: fileURLToPath(new URL('..', import.meta.url)), + filter(ctx) { + const pkg = JSON.parse( + readFileSync(`${ctx.packageRoot}/package.json`, 'utf8'), + ) + if (!pkg.name || pkg.private) + return false + }, +}) diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..4e4222c --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,121 @@ +{ + "compilerOptions": { + "composite": true, + "target": "esnext", + "lib": [ + "esnext" + ], + "rootDir": ".", + "module": "esnext", + "moduleResolution": "Bundler", + "paths": { + "devframe/rpc/transports/ws-server": [ + "./packages/devframe/src/rpc/transports/ws-server.ts" + ], + "devframe/rpc/transports/ws-client": [ + "./packages/devframe/src/rpc/transports/ws-client.ts" + ], + "devframe/rpc/client": [ + "./packages/devframe/src/rpc/client.ts" + ], + "devframe/rpc/server": [ + "./packages/devframe/src/rpc/server.ts" + ], + "devframe/rpc": [ + "./packages/devframe/src/rpc" + ], + "devframe/types": [ + "./packages/devframe/src/types/index.ts" + ], + "devframe/node/auth": [ + "./packages/devframe/src/node/auth/index.ts" + ], + "devframe/node/internal": [ + "./packages/devframe/src/node/internal/index.ts" + ], + "devframe/node": [ + "./packages/devframe/src/node/index.ts" + ], + "devframe/constants": [ + "./packages/devframe/src/constants.ts" + ], + "devframe/utils/colors": [ + "./packages/devframe/src/utils/colors.ts" + ], + "devframe/utils/events": [ + "./packages/devframe/src/utils/events.ts" + ], + "devframe/utils/hash": [ + "./packages/devframe/src/utils/hash.ts" + ], + "devframe/utils/human-id": [ + "./packages/devframe/src/utils/human-id.ts" + ], + "devframe/utils/launch-editor": [ + "./packages/devframe/src/utils/launch-editor.ts" + ], + "devframe/utils/nanoid": [ + "./packages/devframe/src/utils/nanoid.ts" + ], + "devframe/utils/open": [ + "./packages/devframe/src/utils/open.ts" + ], + "devframe/utils/promise": [ + "./packages/devframe/src/utils/promise.ts" + ], + "devframe/utils/serve-static": [ + "./packages/devframe/src/utils/serve-static.ts" + ], + "devframe/utils/shared-state": [ + "./packages/devframe/src/utils/shared-state.ts" + ], + "devframe/utils/streaming-channel": [ + "./packages/devframe/src/utils/streaming-channel.ts" + ], + "devframe/utils/structured-clone": [ + "./packages/devframe/src/utils/structured-clone.ts" + ], + "devframe/utils/when": [ + "./packages/devframe/src/utils/when.ts" + ], + "devframe/adapters/cli": [ + "./packages/devframe/src/adapters/cli.ts" + ], + "devframe/adapters/dev": [ + "./packages/devframe/src/adapters/dev.ts" + ], + "devframe/adapters/build": [ + "./packages/devframe/src/adapters/build.ts" + ], + "devframe/adapters/vite": [ + "./packages/devframe/src/adapters/vite.ts" + ], + "devframe/adapters/embedded": [ + "./packages/devframe/src/adapters/embedded.ts" + ], + "devframe/adapters/mcp": [ + "./packages/devframe/src/adapters/mcp.ts" + ], + "@devframes/nuxt/runtime/plugin.client": [ + "./packages/nuxt/src/runtime/plugin.client.ts" + ], + "@devframes/nuxt": [ + "./packages/nuxt/src/index.ts" + ], + "devframe/recipes/open-helpers": [ + "./packages/devframe/src/recipes/open-helpers.ts" + ], + "devframe/client": [ + "./packages/devframe/src/client/index.ts" + ], + "devframe": [ + "./packages/devframe/src" + ] + }, + "resolveJsonModule": true, + "strict": true, + "noEmit": true, + "isolatedDeclarations": false, + "skipLibCheck": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 9f9ceaf..725f316 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,4 @@ { - "compilerOptions": { - "target": "ESNext", - "lib": ["ESNext"], - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "strict": true, - "strictNullChecks": true, - "noEmit": true, - "esModuleInterop": true, - "verbatimModuleSyntax": true, - "skipDefaultLibCheck": true, - "skipLibCheck": true - } + "extends": "./tsconfig.base.json", + "files": [] } diff --git a/tsdown.config.ts b/tsdown.config.ts deleted file mode 100644 index 8c48a06..0000000 --- a/tsdown.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'tsdown' -import { StaleGuardRecorder } from 'tsdown-stale-guard' - -export default defineConfig({ - entry: [ - 'src/index.ts', - ], - dts: true, - exports: true, - publint: true, - plugins: [ - StaleGuardRecorder(), - ], -}) diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..b8f43f2 --- /dev/null +++ b/turbo.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["pnpm-lock.yaml"], + "tasks": { + "devframe#build": { + "outputLogs": "new-only", + "outputs": ["dist/**"] + }, + "@devframes/nuxt#build": { + "outputLogs": "new-only", + "outputs": ["dist/**"] + }, + "devframe-files-inspector-example#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, + "devframe-streaming-chat-example#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 16e4a02..c5a96b3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,5 +2,12 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { + projects: [ + 'packages/devframe', + 'examples/devframe-files-inspector', + 'examples/devframe-streaming-chat', + 'tests', + ], + testTimeout: 10000, }, }) From 31b2c1f9af3c258a82a26adb24b2a76e9cc21537 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 11 May 2026 14:54:17 +0900 Subject: [PATCH 2/2] ci: grant contents: read permission The empty `permissions: {}` block strips the default token permissions, leaving only `metadata: read`. Checkout in the reusable workflow then hits "Repository not found" because it can't authenticate the fetch. --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31606ae..510def7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,8 @@ on: pull_request: branches: [main] -permissions: {} +permissions: + contents: read jobs: unit-test: