diff --git a/cli/templates/files/saas-starter/app/page.tsx b/cli/templates/files/saas-starter/app/page.tsx index fd8670874a..825340dc29 100644 --- a/cli/templates/files/saas-starter/app/page.tsx +++ b/cli/templates/files/saas-starter/app/page.tsx @@ -31,8 +31,7 @@ export default function LandingPage(): React.JSX.Element { Your AI-powered platform

- Built with Veryfront. Agents, tools, and memory are ready for - production. + Built with Veryfront. Agents, tools, and memory are ready for production.

(
diff --git a/cli/templates/files/saas-starter/tools/search.ts b/cli/templates/files/saas-starter/tools/search.ts index a66d43a27a..58f60eb84f 100644 --- a/cli/templates/files/saas-starter/tools/search.ts +++ b/cli/templates/files/saas-starter/tools/search.ts @@ -4,9 +4,11 @@ import { defineSchema } from "veryfront/schemas"; export default tool({ id: "search", description: "Search your knowledge base", - inputSchema: defineSchema((v) => v.object({ - query: v.string().describe("Search query"), - }))(), + inputSchema: defineSchema((v) => + v.object({ + query: v.string().describe("Search query"), + }) + )(), execute: async ({ query }) => { // Replace with your domain-specific search logic return { diff --git a/cli/templates/index.test.ts b/cli/templates/index.test.ts index 6bca530591..de58dc9891 100644 --- a/cli/templates/index.test.ts +++ b/cli/templates/index.test.ts @@ -76,6 +76,14 @@ describe("cli/templates", () => { } }); + it("does not make baseline framework extensions starter-specific", async () => { + const files = await getTemplate("saas-starter"); + assertExists(files); + + assertEquals(files.some((file) => file.path === "veryfront.config.ts"), false); + assertEquals(templateConfigs["saas-starter"], undefined); + }); + it("imports globals.css from each styled starter root layout", async () => { for (const templateName of STYLED_STARTER_TEMPLATES) { const layoutPath = new URL(`./files/${templateName}/app/layout.tsx`, import.meta.url); diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index ae868ae738..f1bdc7c006 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -99,11 +99,11 @@ "app/dashboard/page.tsx": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Chat } from \"veryfront/chat\";\n\ninterface Conversation {\n id: string;\n title: string;\n updatedAt: string;\n}\n\nconst INITIAL_CONVERSATIONS: Conversation[] = [\n { id: \"1\", title: \"Getting started\", updatedAt: \"Just now\" },\n];\n\nexport default function Dashboard(): React.JSX.Element {\n const [conversations] = useState(INITIAL_CONVERSATIONS);\n const [activeId, setActiveId] = useState(\"1\");\n\n return (\n
\n {/* Sidebar */}\n \n\n {/* Chat */}\n
\n \n
\n
\n );\n}\n", "app/layout.tsx": "import \"../globals.css\";\nimport { Head } from \"veryfront/head\";\n\nexport default function RootLayout({\n children,\n}: {\n children: React.ReactNode;\n}): React.ReactNode {\n return (\n <>\n \n AI SaaS\n \n \n
\n {children}\n
\n \n );\n}\n", "app/login/page.tsx": "\"use client\";\n\n// Demo sign-in: the buttons below pass straight through to /dashboard so the\n// starter is usable out of the box. To wire up real OAuth, scaffold provider\n// routes at app/api/auth/google/route.ts and app/api/auth/github/route.ts and\n// point the hrefs there. See https://veryfront.com/docs/code/guides/oauth.\nexport default function LoginPage(): React.JSX.Element {\n return (\n
\n
\n
\n

\n Welcome back\n

\n

\n Sign in to continue\n

\n
\n\n
\n \n \n \n \n \n \n \n Continue with Google\n \n \n \n \n \n Continue with GitHub\n \n
\n\n

\n \n ← Back to home\n \n

\n
\n
\n );\n}\n", - "app/page.tsx": "export default function LandingPage(): React.JSX.Element {\n return (\n
\n {/* Nav */}\n \n\n {/* Hero */}\n
\n
\n

\n Your AI-powered platform\n

\n

\n Built with Veryfront. Agents, tools, and memory are ready for\n production.\n

\n
\n \n Start free\n \n \n Documentation\n \n
\n
\n\n {/* Features */}\n
\n {[\n {\n title: \"AI Agents\",\n desc: \"Define agents with tools, memory, and streaming. Veryfront auto-discovers them from your project.\",\n },\n {\n title: \"Per-User Memory\",\n desc: \"Each user gets their own conversation history, persisted across sessions.\",\n },\n {\n title: \"Production Ready\",\n desc: \"Use auth, rate limiting, and deployment to ship to production with one command.\",\n },\n ].map(({ title, desc }) => (\n
\n

\n {title}\n

\n

\n {desc}\n

\n
\n ))}\n
\n
\n
\n );\n}\n", + "app/page.tsx": "export default function LandingPage(): React.JSX.Element {\n return (\n
\n {/* Nav */}\n \n\n {/* Hero */}\n
\n
\n

\n Your AI-powered platform\n

\n

\n Built with Veryfront. Agents, tools, and memory are ready for production.\n

\n
\n \n Start free\n \n \n Documentation\n \n
\n
\n\n {/* Features */}\n
\n {[\n {\n title: \"AI Agents\",\n desc:\n \"Define agents with tools, memory, and streaming. Veryfront auto-discovers them from your project.\",\n },\n {\n title: \"Per-User Memory\",\n desc: \"Each user gets their own conversation history, persisted across sessions.\",\n },\n {\n title: \"Production Ready\",\n desc:\n \"Use auth, rate limiting, and deployment to ship to production with one command.\",\n },\n ].map(({ title, desc }) => (\n
\n

\n {title}\n

\n

\n {desc}\n

\n
\n ))}\n
\n
\n
\n );\n}\n", "globals.css": "@import \"tailwindcss\";\n", "public/favicon.svg": "\n \n \n\n", "README.md": "# SaaS Starter\n\nA SaaS-shaped starter with authentication, conversation memory, and a full UI.\n\n## What's included\n\n- Landing page with feature highlights\n- OAuth login (Google and GitHub)\n- Dashboard with conversation sidebar\n- Per-user conversation memory persisted across sessions\n\n## Structure\n\n```\nagents/assistant.ts Agent with conversation memory\ntools/search.ts Placeholder domain search\napp/\n api/ag-ui/route.ts AG-UI endpoint\n page.tsx Landing page\n login/page.tsx OAuth login\n dashboard/page.tsx Chat with sidebar\n```\n\nThis starter is not production-ready.\n", - "tools/search.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\n\nexport default tool({\n id: \"search\",\n description: \"Search your knowledge base\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query\"),\n }))(),\n execute: async ({ query }) => {\n // Replace with your domain-specific search logic\n return {\n results: [],\n query,\n message: \"Connect your data source for real results.\",\n };\n },\n});\n", + "tools/search.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\n\nexport default tool({\n id: \"search\",\n description: \"Search your knowledge base\",\n inputSchema: defineSchema((v) =>\n v.object({\n query: v.string().describe(\"Search query\"),\n })\n )(),\n execute: async ({ query }) => {\n // Replace with your domain-specific search logic\n return {\n results: [],\n query,\n message: \"Connect your data source for real results.\",\n };\n },\n});\n", "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"strict\": true,\n \"jsx\": \"react-jsx\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"paths\": {\n \"@/*\": [\"./*\"]\n }\n },\n \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n \"exclude\": [\"node_modules\"]\n}\n" } }, diff --git a/docs/architecture/12-extension-system.md b/docs/architecture/12-extension-system.md index 1d6f6f5625..1b83bbab8c 100644 --- a/docs/architecture/12-extension-system.md +++ b/docs/architecture/12-extension-system.md @@ -36,16 +36,17 @@ request classification, application authorization, request-to-socket correlation, cancellation, and shutdown ownership. Those responsibilities do not require a WebSocket protocol package and remain in the runtime adapter. -The wire-protocol implementation is supplied by the explicitly activated +The wire-protocol implementation is supplied by the default `@veryfront/ext-node-websocket-ws` package through the dependency-free `NodeWebSocketServerProvider` contract. Bootstrap snapshots one immutable provider generation before it starts a listener, so later mutation or extension reload cannot change the implementation underneath a running server. -The extension is deliberately neither built in nor auto-loaded. Core does not -probe for `ws` or substitute another implementation. A Node HTTP server can run -without the provider, but an authorized WebSocket upgrade fails closed and -identifies the extension needed to enable the feature. +The standard npm/CLI distribution installs and auto-activates the extension; +custom service distributions install the package for Node WebSocket support. +Core does not import `ws`, probe for it, or substitute another implementation. +A Node HTTP server can run without the provider, but an authorized WebSocket +upgrade fails closed and identifies the extension needed to restore the feature. ## Boundaries diff --git a/docs/architecture/20-support-matrix.md b/docs/architecture/20-support-matrix.md index 0d22d32dfe..72fef25b01 100644 --- a/docs/architecture/20-support-matrix.md +++ b/docs/architecture/20-support-matrix.md @@ -60,7 +60,7 @@ project. | ----------------------------- | --------------------------------------------------------- | ------------------------------------- | --------------------------------------- | ---------------------------- | | `SchemaValidator` | `@veryfront/ext-schema-zod` | Built-in | Schema-backed runtime validation | None (pure JS) | | `Bundler`, `ModuleLexer` | `@veryfront/ext-bundler-esbuild` | Built-in | Build, import analysis, module bundling | esbuild binary | -| `CSSProcessor` | `@veryfront/ext-css-tailwind` | Built-in | Tailwind CSS processing | Network (esm.sh for plugins) | +| `CSSProcessor` | `@veryfront/ext-css-tailwind` | Built-in | Tailwind CSS processing | Filesystem read (pinned CSS) | | `ContentProcessor` | `@veryfront/ext-content-mdx` | Built-in | MDX or Markdown content compilation | None (unified ecosystem) | | `CodeParser` | `@veryfront/ext-parser-babel` | Built-in | AST parsing or build-time code analysis | None (Babel) | | `DocumentExtractor` | `@veryfront/ext-document-kreuzberg` | Built-in | Document text extraction | FS (WASM/native extraction) | @@ -73,7 +73,7 @@ project. | `NodeTelemetryProvider` | `@veryfront/ext-observability-opentelemetry` | Built-in | Node OpenTelemetry SDK bootstrap | Network (OTLP endpoint) | | `EvalReportExporterRegistry` | Core registry and future `@veryfront/ext-eval-*` packages | Built-in registry, optional exporters | Eval report export | Vendor-specific | | `TokenCacheStore` | `@veryfront/ext-cache-redis` | Optional | Redis-backed token cache | Network (Redis) | -| `NodeWebSocketServerProvider` | `@veryfront/ext-node-websocket-ws` | Explicit | Node.js WebSocket upgrades | Node.js and scoped env reads | +| `NodeWebSocketServerProvider` | `@veryfront/ext-node-websocket-ws` | Built-in | Node.js WebSocket upgrades and HMR | Node.js and scoped env reads | ## Dependency boundaries diff --git a/docs/guides/extensions.md b/docs/guides/extensions.md index 01f8316dc6..5815d137b6 100644 --- a/docs/guides/extensions.md +++ b/docs/guides/extensions.md @@ -161,26 +161,27 @@ fallback. ## Enable Node.js WebSocket upgrades -Install the explicit Node.js transport extension: +The standard `veryfront` npm/CLI distribution installs and auto-activates the +Node.js transport extension, including for local HMR. Custom Node service +distributions must install it alongside `veryfront`: ```bash deno add npm:@veryfront/ext-node-websocket-ws ``` -Add it to `veryfront.config.ts`: +No `veryfront.config.ts` entry is required. To disable the builtin when +WebSocket support is intentionally unavailable, use: ```ts import { defineConfig } from "veryfront"; -import extNodeWebSocketWs from "@veryfront/ext-node-websocket-ws"; export default defineConfig({ - extensions: [extNodeWebSocketWs()], + extensions: [{ name: "ext-node-websocket-ws", enabled: false }], }); ``` -Restart the Node.js server after changing the configuration. HTTP serving does -not require this extension. Without it, Node.js WebSocket upgrades fail closed -with an error that names the required package. +HTTP serving does not require the provider. Without it, Node.js WebSocket +upgrades fail closed with an error that names the required package. ## First-party extension areas @@ -207,7 +208,7 @@ replacement passes preflight. ## Verify it worked -Restart `veryfront dev` after editing `veryfront.config.ts`: +Restart `veryfront dev` after changing extension configuration: - The dev log should print a setup line for each loaded extension. - Any contract the extension provides should now be resolvable through the diff --git a/extensions/README.md b/extensions/README.md index 7d5dafdc74..e2da58c180 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -38,20 +38,29 @@ Extension availability is separate from contract requirement: ### Build -| Package | Contract | Description | -| --------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------- | -| [`@veryfront/ext-bundler-esbuild`](./ext-bundler-esbuild) | `Bundler`, `ModuleLexer` | ESM bundling and module analysis via `esbuild` and `es-module-lexer` | -| [`@veryfront/ext-css-lightning`](./ext-css-lightning) | `CSSOptimizationEngine` | Explicit CSS compilation, minification, browser targets, and source maps | -| [`@veryfront/ext-css-purgecss`](./ext-css-purgecss) | `CSSPurgingEngine` | Explicit parser-backed unused and critical CSS extraction via PurgeCSS | -| [`@veryfront/ext-css-tailwind`](./ext-css-tailwind) | `CSSProcessor` | Tailwind CSS v4 compilation with pinned local plugins | -| [`@veryfront/ext-image-sharp`](./ext-image-sharp) | `ImageOptimizationEngine` | Explicit bounded native image transformation via Sharp | -| [`@veryfront/ext-parser-babel`](./ext-parser-babel) | `CodeParser` | JS/TS AST parsing, traversal, and JSX source-position injection via Babel | +| Package | Contract | Description | +| --------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------- | +| [`@veryfront/ext-bundler-esbuild`](./ext-bundler-esbuild) | `Bundler`, `ModuleLexer` | ESM bundling and module analysis via `esbuild` and `es-module-lexer` | +| [`@veryfront/ext-css-lightning`](./ext-css-lightning) | `CSSOptimizationEngine` | Explicit CSS compilation, minification, browser targets, and source maps | +| [`@veryfront/ext-css-purgecss`](./ext-css-purgecss) | `CSSPurgingEngine` | Explicit parser-backed unused and critical CSS extraction via PurgeCSS | +| [`@veryfront/ext-css-tailwind`](./ext-css-tailwind) | `CSSProcessor` | Tailwind CSS v4 compilation with pinned local plugins | +| [`@veryfront/ext-image-sharp`](./ext-image-sharp) | `ImageOptimizationEngine` | Explicit bounded native image transformation via Sharp | +| [`@veryfront/ext-parser-babel`](./ext-parser-babel) | `CodeParser` | JS/TS AST parsing, traversal, and JSX source-position injection via Babel | ### Content -| Package | Contract | Description | -| ------------------------------------------------- | ------------------ | ----------------------------------------------------- | -| [`@veryfront/ext-content-mdx`](./ext-content-mdx) | `ContentProcessor` | MDX and Markdown processing via unified/remark/rehype | +| Package | Contract | Description | +| ------------------------------------------------- | ----------------------------- | ----------------------------------------------------- | +| [`@veryfront/ext-content-mdx`](./ext-content-mdx) | `ContentProcessor` | MDX and Markdown processing via unified/remark/rehype | +| [`@veryfront/ext-yaml`](./ext-yaml) | `SkillDocumentParserProvider` | YAML parsing for skill and agent documents | + +### Development and rendering + +| Package | Contract | Description | +| ------------------------------------------------------------- | ----------------------------- | ---------------------------------------------- | +| [`@veryfront/ext-dev-ui-react`](./ext-dev-ui-react) | `DevUiAssetProvider` | Offline assets for local development UI | +| [`@veryfront/ext-node-websocket-ws`](./ext-node-websocket-ws) | `NodeWebSocketServerProvider` | Node.js WebSocket upgrades and local HMR | +| [`@veryfront/ext-react-ssr`](./ext-react-ssr) | `IsolatedSsrRendererProvider` | Explicit renderer for isolated project workers | ### Document extraction @@ -102,26 +111,60 @@ the same contract. | ----------------------------------------------------------------- | --------------------------- | ------------------------------------------- | | [`@veryfront/ext-sandbox-shell-tools`](./ext-sandbox-shell-tools) | `SandboxShellToolsProvider` | Sandbox shell tool creation via `bash-tool` | -## Auto-enabled core extensions - -These extensions are loaded by `createBuiltinExtensions()` during app bootstrap -unless a project disables or overrides them by name. In npm installs, the root -`veryfront` package lazy-loads the matching `@veryfront/ext-*` package for -feature-specific implementations instead of shipping those dependencies in the -root package. - -| Package | Contracts | -| ------------------------------------ | --------------------------- | -| `@veryfront/ext-schema-zod` | `SchemaValidator` | -| `@veryfront/ext-bundler-esbuild` | `Bundler`, `ModuleLexer` | -| `@veryfront/ext-parser-babel` | `CodeParser` | -| `@veryfront/ext-content-mdx` | `ContentProcessor` | -| `@veryfront/ext-document-kreuzberg` | `DocumentExtractor` | -| `@veryfront/ext-db-sqlite` | `SqliteStore` | -| `@veryfront/ext-sandbox-shell-tools` | `SandboxShellToolsProvider` | -| `@veryfront/ext-llm-openai` | `LLMProvider:openai` | -| `@veryfront/ext-llm-anthropic` | `LLMProvider:anthropic` | -| `@veryfront/ext-llm-google` | `LLMProvider:google` | +## Built-in first-party selection + +These packages are known to `createBuiltinExtensions()`, but that does not make +every contract an unconditional global default. Direct built-ins are provided +by their owning source or service distribution. Deferred candidates load only +when the matching feature selects their contract and the executing distribution +contains the package. The standard `veryfront` npm/CLI package installs the +baseline subset used by ordinary apps and local development. + +| Package | Contract | Selection and availability | +| -------------------------------------------- | ----------------------------- | ----------------------------------------- | +| `@veryfront/ext-schema-zod` | `SchemaValidator` | Direct built-in | +| `@veryfront/ext-auth-jwt` | `AuthProvider` | Deferred; install when auth is configured | +| `@veryfront/ext-bundler-esbuild` | `Bundler`, `ModuleLexer` | Deferred; standard npm baseline | +| `@veryfront/ext-parser-babel` | `CodeParser` | Deferred; standard npm baseline | +| `@veryfront/ext-yaml` | `SkillDocumentParserProvider` | Deferred; standard npm baseline | +| `@veryfront/ext-content-mdx` | `ContentProcessor` | Deferred; standard npm baseline | +| `@veryfront/ext-css-tailwind` | `CSSProcessor` | Deferred; standard npm baseline | +| `@veryfront/ext-node-websocket-ws` | `NodeWebSocketServerProvider` | Deferred; standard npm baseline | +| `@veryfront/ext-dev-ui-react` | `DevUiAssetProvider` | Deferred; standard npm baseline | +| `@veryfront/ext-document-kreuzberg` | `DocumentExtractor` | Deferred; source/service distribution | +| `@veryfront/ext-db-sqlite` | `SqliteStore` | Deferred; source/service distribution | +| `@veryfront/ext-sandbox-shell-tools` | `SandboxShellToolsProvider` | Deferred; source/service distribution | +| `@veryfront/ext-observability-opentelemetry` | `TracingExporter` | Deferred; install when OTLP is configured | +| `@veryfront/ext-observability-opentelemetry` | `NodeTelemetryProvider` | Built into the owning agent service | +| `@veryfront/ext-eval-report-http` | Generic HTTP eval exporter | Deferred; source/service distribution | +| `@veryfront/ext-eval-report-mlflow` | MLflow eval exporter | Deferred; source/service distribution | +| `@veryfront/ext-llm-openai` | `LLMProvider:openai` | Direct service/source built-in | +| `@veryfront/ext-llm-anthropic` | `LLMProvider:anthropic` | Direct service/source built-in | +| `@veryfront/ext-llm-google` | `LLMProvider:google` | Direct service/source built-in | + +## Explicit opt-in extensions + +These packages must not become global defaults because they choose mutually +exclusive infrastructure, change build output, load native processors, or +select a specialized isolation implementation. + +| Packages | Why activation stays explicit | +| ------------------------------------------------------------- | ----------------------------------------------------- | +| `@veryfront/ext-blob-s3`, `@veryfront/ext-blob-gcs` | Competing credentialed `BlobStorage` implementations | +| `@veryfront/ext-cache-redis`, `@veryfront/ext-redis` | External Redis topology and credentials | +| `@veryfront/ext-css-lightning`, `@veryfront/ext-css-purgecss` | Build-output policy and native/parser cost | +| `@veryfront/ext-image-sharp` | Native image processing and output policy | +| `@veryfront/ext-react-ssr` | Isolated-worker renderer selected by hosting topology | + +## Service-conditional extensions + +`@veryfront/ext-observability-sentry` is loaded by the owning server or agent +service only after its Sentry enablement, reporter-selection, and DSN policy +passes. It is not a normal application contract extension: automatically +running its package factory would register no useful contract, while eagerly +initializing the SDK would change process-wide error reporting and network +egress. Compiled services may embed the dormant adapter; npm services install +the runtime-specific package they execute. ## npm service installs @@ -130,23 +173,24 @@ raw transitive dependencies such as `bash-tool`, `just-bash`, `jose`, `better-sqlite3`, `@aws-sdk/client-s3`, `@kreuzberg/node`, `@mdx-js/mdx`, or `tailwindcss` directly to satisfy Veryfront runtime features. -| Runtime or service role | Install these extension packages | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| CLI, build image, or project server runtime | `@veryfront/ext-bundler-esbuild`, `@veryfront/ext-content-mdx`, `@veryfront/ext-css-tailwind`, `@veryfront/ext-parser-babel` | -| Build with CSS optimization | `@veryfront/ext-css-lightning` (register explicitly) | -| Build with CSS purging or critical CSS | `@veryfront/ext-css-purgecss` (register explicitly) | -| Build with image optimization | `@veryfront/ext-image-sharp` (register explicitly) | -| Proxy or JWT-authenticated service | `@veryfront/ext-auth-jwt` | -| Document upload or knowledge ingestion | `@veryfront/ext-document-kreuzberg` | -| Redis-backed cache or token store | `@veryfront/ext-cache-redis` | -| Redis-backed distributed runtime or Pub/Sub | `@veryfront/ext-redis` | -| S3-compatible blob persistence | `@veryfront/ext-blob-s3` | -| Google Cloud Storage blob persistence | `@veryfront/ext-blob-gcs` | -| SQLite-backed persistence | `@veryfront/ext-db-sqlite` | -| OpenTelemetry export or Node telemetry | `@veryfront/ext-observability-opentelemetry` | -| Sentry application error capture | `@veryfront/ext-observability-sentry` | -| Local shell-tool agent runtime | `@veryfront/ext-sandbox-shell-tools` | -| Eval report export to MLflow | `@veryfront/ext-eval-report-mlflow` | +| Runtime or service role | Install these extension packages | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CLI, build image, or project server runtime | `@veryfront/ext-bundler-esbuild`, `@veryfront/ext-content-mdx`, `@veryfront/ext-css-tailwind`, `@veryfront/ext-dev-ui-react`, `@veryfront/ext-node-websocket-ws`, `@veryfront/ext-parser-babel`, `@veryfront/ext-yaml` | +| Build with CSS optimization | `@veryfront/ext-css-lightning` (register explicitly) | +| Build with CSS purging or critical CSS | `@veryfront/ext-css-purgecss` (register explicitly) | +| Build with image optimization | `@veryfront/ext-image-sharp` (register explicitly) | +| Proxy or JWT-authenticated service | `@veryfront/ext-auth-jwt` | +| Document upload or knowledge ingestion | `@veryfront/ext-document-kreuzberg` | +| Redis-backed cache or token store | `@veryfront/ext-cache-redis` | +| Redis-backed distributed runtime or Pub/Sub | `@veryfront/ext-redis` | +| S3-compatible blob persistence | `@veryfront/ext-blob-s3` | +| Google Cloud Storage blob persistence | `@veryfront/ext-blob-gcs` | +| SQLite-backed persistence | `@veryfront/ext-db-sqlite` | +| OpenTelemetry export or Node telemetry | `@veryfront/ext-observability-opentelemetry` | +| Sentry application error capture | `@veryfront/ext-observability-sentry` | +| Local shell-tool agent runtime | `@veryfront/ext-sandbox-shell-tools` | +| Eval report export to a generic HTTP gateway | `@veryfront/ext-eval-report-http` | +| Eval report export to MLflow | `@veryfront/ext-eval-report-mlflow` | An agent runtime needs `@veryfront/ext-sandbox-shell-tools` only when it creates local bash or shell tools. MCP-only remote tool execution does not need that @@ -157,27 +201,28 @@ package unless the service also provides local shell tools. Veryfront treats contracts as required at the call site, not at the package list level. -| Contract | Required when | Default source | -| ---------------------------- | ----------------------------------------------- | ------------------------------------- | -| `SchemaValidator` | Schema-backed runtime validation runs | Auto-enabled core extension | -| `Bundler`, `ModuleLexer` | Build, import analysis, or module bundling runs | Auto-enabled core extension | -| `CodeParser` | AST parsing or build-time code analysis runs | Auto-enabled core extension | -| `ContentProcessor` | MDX or Markdown content compilation runs | Auto-enabled core extension | -| `CSSProcessor` | Class-candidate CSS processing runs | Explicit user-installed extension | -| `CSSOptimizationEngine` | CSS compilation or minification runs | Explicit user-installed extension | -| `CSSPurgingEngine` | CSS purging or critical-CSS extraction runs | Explicit user-installed extension | -| `ImageOptimizationEngine` | Image optimization runs | Explicit user-installed extension | -| `DocumentExtractor` | Document text extraction runs | Auto-enabled native service extension | -| `SqliteStore` | SQLite-backed persistence runs | Auto-enabled native service extension | -| `SandboxShellToolsProvider` | Sandbox shell tools are created | Auto-enabled core extension | -| `LLMProvider:*` | A matching model provider is selected | Auto-enabled core extension | -| `BlobStorage` | S3 or GCS object persistence is configured | Explicitly configured extension | -| `AuthProvider` | Auth signing or verification is configured | User-installed extension | -| `TokenCacheStore` | Redis-backed token cache is configured | User-installed extension | -| `RedisRuntimeProvider` | A core Redis facade or Pub/Sub is used | Explicitly configured extension | -| `EvalReportExporterRegistry` | Eval report exporters are registered | Auto-enabled core extension | -| `TracingExporter` | OTLP tracing export is configured | User-installed extension | -| `NodeTelemetryProvider` | Node agent service telemetry is enabled | Auto-enabled agent service extension | +| Contract | Required when | Default source | +| ----------------------------- | ----------------------------------------------- | ------------------------------------- | +| `SchemaValidator` | Schema-backed runtime validation runs | Auto-enabled core extension | +| `Bundler`, `ModuleLexer` | Build, import analysis, or module bundling runs | Auto-enabled core extension | +| `CodeParser` | AST parsing or build-time code analysis runs | Auto-enabled core extension | +| `ContentProcessor` | MDX or Markdown content compilation runs | Auto-enabled core extension | +| `CSSProcessor` | Class-candidate CSS processing runs | Auto-enabled core extension | +| `CSSOptimizationEngine` | CSS compilation or minification runs | Explicit user-installed extension | +| `CSSPurgingEngine` | CSS purging or critical-CSS extraction runs | Explicit user-installed extension | +| `ImageOptimizationEngine` | Image optimization runs | Explicit user-installed extension | +| `DocumentExtractor` | Document text extraction runs | Auto-enabled native service extension | +| `SqliteStore` | SQLite-backed persistence runs | Auto-enabled native service extension | +| `SandboxShellToolsProvider` | Sandbox shell tools are created | Auto-enabled core extension | +| `LLMProvider:*` | A matching model provider is selected | Auto-enabled core extension | +| `BlobStorage` | S3 or GCS object persistence is configured | Explicitly configured extension | +| `AuthProvider` | Auth signing or verification is configured | User-installed extension | +| `TokenCacheStore` | Redis-backed token cache is configured | User-installed extension | +| `RedisRuntimeProvider` | A core Redis facade or Pub/Sub is used | Explicitly configured extension | +| `EvalReportExporterRegistry` | Eval report exporters are registered | Auto-enabled core extension | +| `TracingExporter` | OTLP tracing export is configured | User-installed extension | +| `NodeTelemetryProvider` | Node agent service telemetry is enabled | Auto-enabled agent service extension | +| `NodeWebSocketServerProvider` | Node.js WebSocket upgrades or HMR run | Auto-enabled core extension | ## Architecture diff --git a/extensions/ext-css-tailwind/README.md b/extensions/ext-css-tailwind/README.md index b31679064e..47ca795547 100644 --- a/extensions/ext-css-tailwind/README.md +++ b/extensions/ext-css-tailwind/README.md @@ -1,25 +1,19 @@ # @veryfront/ext-css-tailwind -> **Category:** Build | **Contract:** `CSSProcessor` | **Explicit** +> **Category:** Build | **Contract:** `CSSProcessor` | **Default** Provides Tailwind CSS v4 compilation for Veryfront. The extension owns the pinned compiler, local base stylesheet, plugin policy, plugin module loading, and every third-party import; framework core sees only `CSSProcessor`. -## Registration +## Activation -Install and compose the extension explicitly: +The standard `veryfront` npm/CLI distribution installs and auto-activates this +extension. Source and custom service distributions must make the package +available alongside `veryfront`; the builtin composition then activates it. +Projects do not need a Tailwind entry in `veryfront.config.ts`. -```ts -import extTailwind from "@veryfront/ext-css-tailwind"; - -export default defineConfig({ - extensions: [extTailwind()], -}); -``` - -Core never discovers or auto-registers this provider. Production pipelines -that request CSS minification must also explicitly compose a +Production pipelines that request CSS minification must still explicitly compose a `CSSOptimizationEngine` provider such as `@veryfront/ext-css-lightning`. If either requested provider is absent, compilation fails instead of returning empty or regex-rewritten CSS. diff --git a/extensions/ext-css-tailwind/deno.json b/extensions/ext-css-tailwind/deno.json index 9c36287ef7..da327464a3 100644 --- a/extensions/ext-css-tailwind/deno.json +++ b/extensions/ext-css-tailwind/deno.json @@ -4,7 +4,7 @@ "exports": "./src/index.ts", "veryfront": { "extension": true, - "activation": "explicit", + "activation": "auto", "contracts": { "provides": ["CSSProcessor"] }, diff --git a/extensions/ext-css-tailwind/src/index.test.ts b/extensions/ext-css-tailwind/src/index.test.ts index bc857ff705..50ef8edfaa 100644 --- a/extensions/ext-css-tailwind/src/index.test.ts +++ b/extensions/ext-css-tailwind/src/index.test.ts @@ -12,7 +12,7 @@ const noopLogger = { debug() {}, info() {}, warn() {}, error() {} }; describe("ext-css-tailwind", () => { it("aligns package, factory, contract, and capability metadata", () => { const extension = factory(); - assertEquals(extensionPackage.veryfront.activation, "explicit"); + assertEquals(extensionPackage.veryfront.activation, "auto"); assertEquals(extension.name, "ext-css-tailwind"); assertEquals(extension.version, extensionPackage.version); assertEquals(extension.contracts?.provides, ["CSSProcessor"]); diff --git a/extensions/ext-eval-report-http/README.md b/extensions/ext-eval-report-http/README.md index 2b005b12a3..3d112bfad8 100644 --- a/extensions/ext-eval-report-http/README.md +++ b/extensions/ext-eval-report-http/README.md @@ -18,9 +18,23 @@ scores, redaction policy, and optional trace correlation. Do not use OTLP runtime telemetry env vars to route eval reports; `OTEL_*` settings only control runtime trace and metric export. -## Installation +## Activation -Add the extension to your project's `veryfront.config.ts`: +Source and compiled Veryfront runtimes select the extension automatically, but +it remains dormant until an HTTP exporter URL is configured and an eval run +selects its exporter id. npm services that need HTTP eval export install +`@veryfront/ext-eval-report-http`; package discovery then selects it +automatically. The standard `veryfront` npm package does not install this +network-export integration for every application. + +Set `VERYFRONT_EVAL_HTTP_EXPORTER_URL` for the default `http` exporter, then run: + +```bash +veryfront eval deep-research --export http +``` + +Register the factory explicitly only when configuration must define multiple +exporters, inject a fetch implementation, or avoid environment configuration: ```ts import extEvalReportHttp from "@veryfront/ext-eval-report-http"; diff --git a/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts b/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts index 4d9d75f6f4..6158b89e21 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-request-builder.ts @@ -2,7 +2,6 @@ import { jsonValuesEqual, readProviderOptions, readRecord, - stringifyJsonValue, stringifyToolResultValue, unwrapToolInputSchema, } from "veryfront/provider/shared"; diff --git a/extensions/ext-llm-openai/src/openai-responses-request-builder.ts b/extensions/ext-llm-openai/src/openai-responses-request-builder.ts index e4d358c5bb..093fa001f2 100644 --- a/extensions/ext-llm-openai/src/openai-responses-request-builder.ts +++ b/extensions/ext-llm-openai/src/openai-responses-request-builder.ts @@ -1,7 +1,6 @@ import { jsonValuesEqual, readProviderOptions, - stringifyJsonValue, stringifyToolArguments, stringifyToolResultValue, unwrapToolInputSchema, diff --git a/extensions/ext-node-websocket-ws/README.md b/extensions/ext-node-websocket-ws/README.md index 9c99d4bcb5..c4b3d9cea9 100644 --- a/extensions/ext-node-websocket-ws/README.md +++ b/extensions/ext-node-websocket-ws/README.md @@ -1,25 +1,20 @@ # `@veryfront/ext-node-websocket-ws` -Explicit Node.js WebSocket transport for Veryfront, backed by the `ws` package. +Default Node.js WebSocket transport for Veryfront, backed by the `ws` package. Core owns request authorization, upgrade correlation, and shutdown. This extension owns the third-party protocol implementation and publishes the dependency-free `NodeWebSocketServerProvider` contract. -Install the package and compose it explicitly: +The standard `veryfront` npm/CLI distribution installs and auto-activates the +extension, so local HMR works without starter-specific configuration. A custom +Node service distribution must install the package alongside `veryfront`: ```bash deno add npm:@veryfront/ext-node-websocket-ws ``` -```ts -import { defineConfig } from "veryfront"; -import extNodeWebSocketWs from "@veryfront/ext-node-websocket-ws"; - -export default defineConfig({ - extensions: [extNodeWebSocketWs()], -}); -``` - -Veryfront never auto-loads this package and has no built-in WebSocket fallback. -Without an explicitly registered provider, Node HTTP requests remain available -but Node WebSocket upgrades fail closed with an actionable diagnostic. +Projects can disable the builtin with +`{ name: "ext-node-websocket-ws", enabled: false }`. Without an available +provider, Node HTTP requests remain available but Node WebSocket upgrades fail +closed with an actionable diagnostic. Core never imports `ws` or substitutes a +hidden protocol fallback. diff --git a/extensions/ext-node-websocket-ws/deno.json b/extensions/ext-node-websocket-ws/deno.json index c0923b3db3..838b59b8b4 100644 --- a/extensions/ext-node-websocket-ws/deno.json +++ b/extensions/ext-node-websocket-ws/deno.json @@ -4,7 +4,7 @@ "exports": "./src/index.ts", "veryfront": { "extension": true, - "activation": "explicit", + "activation": "auto", "contracts": { "provides": [ "NodeWebSocketServerProvider" diff --git a/extensions/ext-node-websocket-ws/src/index.test.ts b/extensions/ext-node-websocket-ws/src/index.test.ts index e87e2a8cda..f9f2d93a2e 100644 --- a/extensions/ext-node-websocket-ws/src/index.test.ts +++ b/extensions/ext-node-websocket-ws/src/index.test.ts @@ -1,8 +1,10 @@ -import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { assertEquals, assertStrictEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; import { type NodeWebSocketServerProvider, NodeWebSocketServerProviderName, } from "veryfront/extensions/websocket"; +import extensionPackage from "../deno.json" with { type: "json" }; import extNodeWebSocketWs, { WsNodeWebSocketServerProvider } from "./index.ts"; const logger = { @@ -27,52 +29,55 @@ function createContext(provided: Map, signal?: AbortSignal) { }; } -Deno.test("ws extension explicitly publishes one immutable provider", () => { - const extension = extNodeWebSocketWs(); - const provided = new Map(); +describe("extNodeWebSocketWs", () => { + it("auto-activates one immutable provider", () => { + const extension = extNodeWebSocketWs(); + const provided = new Map(); - extension.setup?.(createContext(provided)); - assertStrictEquals( - provided.get(NodeWebSocketServerProviderName), - WsNodeWebSocketServerProvider, - ); - assertEquals(Object.isFrozen(WsNodeWebSocketServerProvider), true); - assertEquals(extension.contracts?.provides, [NodeWebSocketServerProviderName]); - assertEquals(extension.capabilities, [{ - type: "env:read", - keys: ["WS_NO_BUFFER_UTIL", "WS_NO_UTF_8_VALIDATE"], - }]); - assertThrows(() => extension.setup?.(createContext(new Map())), Error, "already set up"); + assertEquals(extensionPackage.veryfront.activation, "auto"); + extension.setup?.(createContext(provided)); + assertStrictEquals( + provided.get(NodeWebSocketServerProviderName), + WsNodeWebSocketServerProvider, + ); + assertEquals(Object.isFrozen(WsNodeWebSocketServerProvider), true); + assertEquals(extension.contracts?.provides, [NodeWebSocketServerProviderName]); + assertEquals(extension.capabilities, [{ + type: "env:read", + keys: ["WS_NO_BUFFER_UTIL", "WS_NO_UTF_8_VALIDATE"], + }]); + assertThrows(() => extension.setup?.(createContext(new Map())), Error, "already set up"); - extension.teardown?.(); - extension.setup?.(createContext(new Map())); - extension.teardown?.(); -}); - -Deno.test("ws extension creates a no-server transport", () => { - const provider = WsNodeWebSocketServerProvider as NodeWebSocketServerProvider; - const server = provider.createServer({ - noServer: true, - handleProtocols: () => false, + extension.teardown?.(); + extension.setup?.(createContext(new Map())); + extension.teardown?.(); }); - try { - assertEquals(typeof server.handleUpgrade, "function"); - assertEquals(typeof server.close, "function"); - assertEquals(typeof server.on, "function"); - } finally { - server.close(); - } -}); + it("creates a no-server transport", () => { + const provider = WsNodeWebSocketServerProvider as NodeWebSocketServerProvider; + const server = provider.createServer({ + noServer: true, + handleProtocols: () => false, + }); -Deno.test("ws extension refuses a revoked setup context", () => { - const extension = extNodeWebSocketWs(); - const controller = new AbortController(); - controller.abort(new DOMException("context retired", "AbortError")); + try { + assertEquals(typeof server.handleUpgrade, "function"); + assertEquals(typeof server.close, "function"); + assertEquals(typeof server.on, "function"); + } finally { + server.close(); + } + }); + + it("refuses a revoked setup context", () => { + const extension = extNodeWebSocketWs(); + const controller = new AbortController(); + controller.abort(new DOMException("context retired", "AbortError")); - assertThrows( - () => extension.setup?.(createContext(new Map(), controller.signal)), - DOMException, - "context retired", - ); + assertThrows( + () => extension.setup?.(createContext(new Map(), controller.signal)), + DOMException, + "context retired", + ); + }); }); diff --git a/extensions/ext-node-websocket-ws/src/index.ts b/extensions/ext-node-websocket-ws/src/index.ts index 799143aea7..1db7c23263 100644 --- a/extensions/ext-node-websocket-ws/src/index.ts +++ b/extensions/ext-node-websocket-ws/src/index.ts @@ -1,4 +1,4 @@ -/** Explicit `ws` implementation of Veryfront's Node WebSocket contract. */ +/** Default `ws` implementation of Veryfront's Node WebSocket contract. */ import type { ExtensionFactory } from "veryfront/extensions"; import { @@ -13,7 +13,7 @@ export const WsNodeWebSocketServerProvider = createNodeWebSocketServerProvider( (options) => captureNodeWebSocketServer(new WebSocketServer(options)), ); -/** Create the explicitly selected Node.js `ws` transport extension. */ +/** Create the standard Node.js `ws` transport extension. */ export const extNodeWebSocketWs: ExtensionFactory = () => { let active = false; return { diff --git a/extensions/ext-observability-sentry/README.md b/extensions/ext-observability-sentry/README.md index 9260881c54..775087404d 100644 --- a/extensions/ext-observability-sentry/README.md +++ b/extensions/ext-observability-sentry/README.md @@ -2,6 +2,11 @@ First-party Sentry application error reporter for Veryfront runtimes. +This package is service-conditional rather than an auto-activated application +extension. The server or agent service imports the matching reporter only after +its enablement policy passes; adding the package to `node_modules` alone does +not initialize Sentry or enable network egress. + Enable the adapter explicitly and provide its credential: ```sh diff --git a/extensions/ext-observability-sentry/deno.json b/extensions/ext-observability-sentry/deno.json index 5b6ebb389b..fdf5860b13 100644 --- a/extensions/ext-observability-sentry/deno.json +++ b/extensions/ext-observability-sentry/deno.json @@ -8,6 +8,7 @@ }, "veryfront": { "extension": true, + "activation": "explicit", "npm": { "stagedSources": [ { diff --git a/scripts/build/build-npm-dnt.ts b/scripts/build/build-npm-dnt.ts index 44bf48cff7..c3ed16550d 100644 --- a/scripts/build/build-npm-dnt.ts +++ b/scripts/build/build-npm-dnt.ts @@ -12,6 +12,7 @@ */ import { build, emptyDir } from "#dnt"; +import { STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES } from "#veryfront/extensions/first-party-defaults.ts"; import { BROWSER_SAFE_CLIENT_MODULES, BROWSER_SAFE_DNT_TIMER_MODULES, @@ -174,9 +175,6 @@ await build({ dependencies: { "@types/react": npmDependencyRange(denoConfigSet, "@types/react"), "@types/react-dom": npmDependencyRange(denoConfigSet, "@types/react-dom"), - // Root deno.json intentionally rejects core npm imports; ws is a - // Node-only dynamic import used by the npm server/HMR path. - "ws": "8.21.0", }, keywords: [ "react", @@ -313,16 +311,9 @@ await build({ pkg.dependencies ??= {}; // Add after build-local npm install so releases do not require the // just-built auto-loaded extension versions to already exist in the registry. - pkg.dependencies["@veryfront/ext-bundler-esbuild"] = version; - pkg.dependencies["@veryfront/ext-content-mdx"] = version; - pkg.dependencies["@veryfront/ext-css-tailwind"] = version; - // ext-parser-babel provides the CodeParser contract that `veryfront serve` - // needs to vet client-page modules for /_veryfront/rsc/module hydration; - // without it the endpoint 404s and client pages render without hydrating. - pkg.dependencies["@veryfront/ext-parser-babel"] = version; - // Skill discovery parses YAML through the extension contract; ship the - // first-party implementation while keeping @std/yaml out of core. - pkg.dependencies["@veryfront/ext-yaml"] = version; + for (const extensionDirectory of STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES) { + pkg.dependencies[`@veryfront/${extensionDirectory}`] = version; + } pkg.files = ["esm", "script", "bin", "assets", "tsconfig.json", "LICENSE", "NOTICE", "README.md"]; pkg.exports["./tsconfig.json"] = "./tsconfig.json"; addTypesExportEntries(pkg.exports); @@ -356,11 +347,9 @@ async function verifyNpmRootImportLifecycle(): Promise { async function installBuiltNpmLifecycleConsumer(consumerDirectory: string): Promise { const localPackageDirectories = await Promise.all([ Deno.realPath("./npm"), - Deno.realPath("./npm/extensions/ext-bundler-esbuild"), - Deno.realPath("./npm/extensions/ext-content-mdx"), - Deno.realPath("./npm/extensions/ext-css-tailwind"), - Deno.realPath("./npm/extensions/ext-parser-babel"), - Deno.realPath("./npm/extensions/ext-yaml"), + ...STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES.map((extensionDirectory) => + Deno.realPath(`./npm/extensions/${extensionDirectory}`) + ), ]); await Deno.writeTextFile( `${consumerDirectory}/package.json`, @@ -406,7 +395,7 @@ if (metadata?.name !== "public-api") { throw new Error("public runtime Skill parser default unavailable"); } -const { createEvalCliBuiltinExtensions } = await import( +const { createBuiltinExtensions, createEvalCliBuiltinExtensions } = await import( "./node_modules/veryfront/esm/src/extensions/builtin-extensions.js" ); const { getDeferredExtensionState } = await import( @@ -429,6 +418,34 @@ const logger = { warn() {}, error() {}, }; + +for (const [extensionName, contractName] of [ + ["ext-css-tailwind", "CSSProcessor"], + ["ext-dev-ui-react", "DevUiAssetProvider"], + ["ext-node-websocket-ws", "NodeWebSocketServerProvider"], +]) { + const candidate = createBuiltinExtensions().find( + (entry) => entry.extension.name === extensionName, + ); + if (!candidate) throw new Error("standard builtin missing: " + extensionName); + const standardDeferred = getDeferredExtensionState(candidate); + if (!standardDeferred) throw new Error("standard builtin was not deferred: " + extensionName); + const standardExtension = await standardDeferred.load(logger); + if (!standardExtension) throw new Error("standard builtin failed to load: " + extensionName); + let provided; + await standardExtension.setup?.({ + get() {}, + require(contract) { throw new Error("unexpected extension contract: " + contract); }, + provide(contract, implementation) { + if (contract === contractName) provided = implementation; + }, + config: {}, + logger, + }); + if (!provided) throw new Error("standard builtin did not provide " + contractName); + await standardExtension.teardown?.(); +} + const deferred = getDeferredExtensionState(resolved); if (!deferred) throw new Error("bundled MLflow extension was not deferred"); const extension = await deferred.load(logger); diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 1d9e0dcb9a..4802376632 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -1,8 +1,10 @@ -import { assertEquals } from "#std/assert"; import { walk } from "#std/fs/walk"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; import { createCompileArgs, DEFAULT_INCLUDES } from "./compile-binary.ts"; +import { FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES } from "#veryfront/extensions/first-party-defaults.ts"; -Deno.test("compiled CLI embeds the explicit Node WebSocket extension for opt-in activation", () => { +it("compiled CLI embeds the default Node WebSocket extension for HMR", () => { const args = createCompileArgs({ entrypoint: "cli/main.ts", extraIncludes: [], @@ -15,7 +17,7 @@ Deno.test("compiled CLI embeds the explicit Node WebSocket extension for opt-in ); }); -Deno.test("compiled CLI embeds the explicit Redis extension for opt-in activation", () => { +it("compiled CLI embeds the explicit Redis extension for opt-in activation", () => { const args = createCompileArgs({ entrypoint: "cli/main.ts", extraIncludes: [], @@ -28,16 +30,10 @@ Deno.test("compiled CLI embeds the explicit Redis extension for opt-in activatio ); }); -Deno.test("compiled CLI embeds optional builtin extension source files", async () => { - const source = await Deno.readTextFile( - "src/extensions/builtin-extensions.ts", - ); - const sourceDirectories = Array.from( - source.matchAll(/sourceDirectory:\s*"([^"]+)"/g), - (match) => match[1]!, - ); - - for (const sourceDirectory of sourceDirectories) { +it("compiled CLI embeds optional builtin extension source files", () => { + for ( + const { sourceDirectory } of FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES + ) { assertEquals( DEFAULT_INCLUDES.includes(`extensions/${sourceDirectory}/src/index.ts`), true, @@ -46,7 +42,7 @@ Deno.test("compiled CLI embeds optional builtin extension source files", async ( } }); -Deno.test("compiled CLI embeds every runtime-resolved sibling module", async () => { +it("compiled CLI embeds every runtime-resolved sibling module", async () => { // Modules picked through a `.ts`/`.js` distribution-format ternary are // resolved from a computed URL, so `deno compile` never sees them in the // static graph and only DEFAULT_INCLUDES can embed them. @@ -85,7 +81,7 @@ Deno.test("compiled CLI embeds every runtime-resolved sibling module", async () ); }); -Deno.test("compiled CLI embeds the permissionless parser entry", () => { +it("compiled CLI embeds the permissionless parser entry", () => { assertEquals( DEFAULT_INCLUDES.includes( "extensions/ext-parser-babel/src/parser-only.ts", @@ -94,7 +90,7 @@ Deno.test("compiled CLI embeds the permissionless parser entry", () => { ); }); -Deno.test("compiled CLI embeds the auto-loaded Sentry reporter", () => { +it("compiled CLI embeds the auto-loaded Sentry reporter", () => { assertEquals( DEFAULT_INCLUDES.includes( "extensions/ext-observability-sentry/src/index.ts", diff --git a/scripts/build/compile-binary.ts b/scripts/build/compile-binary.ts index 2b7b036a6b..f37839c86a 100644 --- a/scripts/build/compile-binary.ts +++ b/scripts/build/compile-binary.ts @@ -12,8 +12,8 @@ export const DEFAULT_INCLUDES = [ "src/proxy/main.ts", "src/security/sandbox/worker-script.ts", "extensions/ext-auth-jwt/src/index.ts", - // Embedding explicit extensions makes them available to compiled binaries; - // discovery still requires the caller to select them before activation. + // Explicit extensions remain inert until selected. Default extensions are + // activated by the normal builtin composition when their source is embedded. "extensions/ext-blob-gcs/src/index.ts", "extensions/ext-blob-s3/src/index.ts", "extensions/ext-node-websocket-ws/src/index.ts", diff --git a/scripts/build/npm-package-metadata.test.ts b/scripts/build/npm-package-metadata.test.ts index 02ec32f0c1..614bda6aa8 100644 --- a/scripts/build/npm-package-metadata.test.ts +++ b/scripts/build/npm-package-metadata.test.ts @@ -1,5 +1,9 @@ -import { assertEquals, assertStringIncludes } from "#std/assert"; -import { describe, it } from "#std/testing/bdd"; +import { STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES } from "#veryfront/extensions/first-party-defaults.ts"; +import { + assertEquals, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; import { BROWSER_SAFE_CLIENT_MODULES, BROWSER_SAFE_EXPORTS, @@ -16,7 +20,7 @@ import { type RootPackageConfig, } from "./npm-extension-package-metadata.ts"; -Deno.test("exports agent skill helpers as a public package subpath", async () => { +it("exports agent skill helpers as a public package subpath", async () => { const denoConfig = JSON.parse(await Deno.readTextFile("deno.json")); const exports = denoConfig.exports as Record; const imports = denoConfig.imports as Record; @@ -25,7 +29,7 @@ Deno.test("exports agent skill helpers as a public package subpath", async () => assertEquals(imports["veryfront/skill"], "./src/skill/index.ts"); }); -Deno.test("exports the UI adapter contract as a public package subpath", async () => { +it("exports the UI adapter contract as a public package subpath", async () => { const denoConfig = JSON.parse(await Deno.readTextFile("deno.json")); const exports = denoConfig.exports as Record; const imports = denoConfig.imports as Record; @@ -35,7 +39,7 @@ Deno.test("exports the UI adapter contract as a public package subpath", async ( assertEquals(imports["veryfront/ui/adapter"], contract); }); -Deno.test("exports CLI framework dependencies as public package subpaths", async () => { +it("exports CLI framework dependencies as public package subpaths", async () => { const denoConfig = JSON.parse(await Deno.readTextFile("deno.json")); const exports = denoConfig.exports as Record; const imports = denoConfig.imports as Record; @@ -51,7 +55,7 @@ Deno.test("exports CLI framework dependencies as public package subpaths", async } }); -Deno.test("npm package provenance metadata points at veryfront-code", async () => { +it("npm package provenance metadata points at veryfront-code", async () => { const source = await Deno.readTextFile("scripts/build/build-npm-dnt.ts"); assertStringIncludes( @@ -65,7 +69,7 @@ Deno.test("npm package provenance metadata points at veryfront-code", async () = assertEquals(source.includes("github.com/veryfront/veryfront.git"), false); }); -Deno.test("root npm build metadata does not inject extension implementation dependencies", async () => { +it("root npm build metadata does not inject extension implementation dependencies", async () => { const source = await Deno.readTextFile("scripts/build/build-npm-dnt.ts"); for (const packageName of ["@kreuzberg/node", "better-sqlite3"]) { @@ -77,56 +81,56 @@ Deno.test("root npm build metadata does not inject extension implementation depe } }); -Deno.test("root npm CLI package declares auto-loaded first-party extensions after local install", async () => { +it("standard npm extension policy covers baseline app and developer features", () => { + assertEquals([...STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES], [ + "ext-bundler-esbuild", + "ext-content-mdx", + "ext-css-tailwind", + "ext-dev-ui-react", + "ext-node-websocket-ws", + "ext-parser-babel", + "ext-yaml", + ]); +}); + +it("root npm CLI package declares standard extensions after local install", async () => { const source = await Deno.readTextFile("scripts/build/build-npm-dnt.ts"); const installIndex = source.indexOf( "const { code } = await npmInstall.output();", ); - for ( - const packageName of [ - "@veryfront/ext-bundler-esbuild", - "@veryfront/ext-content-mdx", - "@veryfront/ext-css-tailwind", - "@veryfront/ext-parser-babel", - "@veryfront/ext-yaml", - ] - ) { - const dependencyAssignment = - `pkg.dependencies["${packageName}"] = version;`; - const dependencyIndex = source.indexOf(dependencyAssignment); + const dependencyLoop = + "for (const extensionDirectory of STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES)"; + const dependencyIndex = source.indexOf(dependencyLoop); - assertStringIncludes(source, dependencyAssignment); - assertEquals( - dependencyIndex > installIndex, - true, - `${packageName} dependency must be added after build-local npm install so prerelease builds do not require the extension to already be published`, - ); - } + assertStringIncludes(source, dependencyLoop); + assertStringIncludes( + source, + "pkg.dependencies[`@veryfront/${extensionDirectory}`] = version;", + ); + assertEquals( + dependencyIndex > installIndex, + true, + "standard extension dependencies must be added after the build-local npm install", + ); }); -Deno.test("npm lifecycle probe installs auto-loaded extensions in a real consumer layout", async () => { +it("npm lifecycle probe installs auto-loaded extensions in a real consumer layout", async () => { const source = await Deno.readTextFile("scripts/build/build-npm-dnt.ts"); assertStringIncludes(source, '"--install-links"'); assertStringIncludes(source, 'const agent = await import("veryfront/agent")'); assertStringIncludes(source, "agent.parseRuntimeSkillMetadata("); - for ( - const extensionDirectory of [ - "ext-bundler-esbuild", - "ext-content-mdx", - "ext-css-tailwind", - "ext-parser-babel", - "ext-yaml", - ] - ) { - assertStringIncludes( - source, - `Deno.realPath("./npm/extensions/${extensionDirectory}")`, - ); - } + assertStringIncludes( + source, + "...STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES.map", + ); + assertStringIncludes( + source, + "Deno.realPath(`./npm/extensions/${extensionDirectory}`)", + ); }); -Deno.test("npm publish version bump pins first-party extension dependencies to the publish version", async () => { +it("npm publish version bump pins first-party extension dependencies to the publish version", async () => { const packageDir = await Deno.makeTempDir(); const packagePath = `${packageDir}/package.json`; const publishVersion = "0.1.1016-rc.123"; @@ -143,6 +147,7 @@ Deno.test("npm publish version bump pins first-party extension dependencies to t "@veryfront/ext-bundler-esbuild": "0.1.1016", "@veryfront/ext-content-mdx": "^0.1.1016", "@veryfront/ext-css-tailwind": "^0.1.1016", + "@veryfront/ext-node-websocket-ws": "^0.1.1016", "@veryfront/ext-parser-babel": "^0.1.1016", "@veryfront/ext-yaml": "^0.1.1016", "@veryfront/not-an-extension": "^0.1.1016", @@ -184,6 +189,7 @@ Deno.test("npm publish version bump pins first-party extension dependencies to t "@veryfront/ext-bundler-esbuild": publishVersion, "@veryfront/ext-content-mdx": publishVersion, "@veryfront/ext-css-tailwind": publishVersion, + "@veryfront/ext-node-websocket-ws": publishVersion, "@veryfront/ext-parser-babel": publishVersion, "@veryfront/ext-yaml": publishVersion, "@veryfront/not-an-extension": "^0.1.1016", @@ -197,7 +203,7 @@ Deno.test("npm publish version bump pins first-party extension dependencies to t } }); -Deno.test("npm publish orders extensions before the root package", async () => { +it("npm publish orders extensions before the root package", async () => { const packageRoot = await Deno.makeTempDir(); try { @@ -232,7 +238,7 @@ Deno.test("npm publish orders extensions before the root package", async () => { } }); -Deno.test("npm publish skips extension packages marked publish false", async () => { +it("npm publish skips extension packages marked publish false", async () => { const packageRoot = await Deno.makeTempDir(); try { @@ -331,7 +337,12 @@ const ROOT_BUNDLED_EXTENSIONS = new Set([ "ext-eval-report-mlflow", ]); -Deno.test("EXTENSION_OWNED_DEPENDENCIES stays in sync with extension manifests", async () => { +// The framework and ext-dev-ui-react both consume the application's React +// generation. These remain root dependencies so npm resolves one React graph +// instead of treating the extension's use as private implementation detail. +const ROOT_SHARED_EXTENSION_DEPENDENCIES = new Set(["react", "react-dom"]); + +it("EXTENSION_OWNED_DEPENDENCIES stays in sync with extension manifests", async () => { const denoConfig = JSON.parse( await Deno.readTextFile("deno.json"), ) as RootPackageConfig; @@ -365,7 +376,8 @@ Deno.test("EXTENSION_OWNED_DEPENDENCIES stays in sync with extension manifests", for (const dependency of dependencies) { assertEquals( - owned.has(dependency) || optionalPeers.has(dependency), + owned.has(dependency) || optionalPeers.has(dependency) || + ROOT_SHARED_EXTENSION_DEPENDENCIES.has(dependency), true, `${dependency} (declared by ${manifestPath}) must be added to EXTENSION_OWNED_DEPENDENCIES so it does not leak into root veryfront npm installs`, ); @@ -598,6 +610,8 @@ describe("npm supply-chain policy", () => { "ext-bundler-esbuild", "ext-content-mdx", "ext-css-tailwind", + "ext-dev-ui-react", + "ext-node-websocket-ws", "ext-parser-babel", "ext-yaml", ]; @@ -651,6 +665,7 @@ describe("npm supply-chain policy", () => { "ext-bundler-esbuild", "ext-content-mdx", "ext-css-tailwind", + "ext-node-websocket-ws", "ext-db-sqlite", "ext-document-kreuzberg", "ext-eval-report-mlflow", diff --git a/scripts/test/npm-install-smoke.sh b/scripts/test/npm-install-smoke.sh index c4b4fe1238..0ea126ae76 100755 --- a/scripts/test/npm-install-smoke.sh +++ b/scripts/test/npm-install-smoke.sh @@ -27,6 +27,8 @@ fail() { [ -d "$ROOT_DIR/npm/extensions/ext-bundler-esbuild" ] || fail "ext-bundler-esbuild package output missing" [ -d "$ROOT_DIR/npm/extensions/ext-content-mdx" ] || fail "ext-content-mdx package output missing" [ -d "$ROOT_DIR/npm/extensions/ext-css-tailwind" ] || fail "ext-css-tailwind package output missing" +[ -d "$ROOT_DIR/npm/extensions/ext-dev-ui-react" ] || fail "ext-dev-ui-react package output missing" +[ -d "$ROOT_DIR/npm/extensions/ext-node-websocket-ws" ] || fail "ext-node-websocket-ws package output missing" [ -d "$ROOT_DIR/npm/extensions/ext-parser-babel" ] || fail "ext-parser-babel package output missing" [ -d "$ROOT_DIR/npm/extensions/ext-yaml" ] || fail "ext-yaml package output missing" [ -d "$ROOT_DIR/npm/extensions/ext-auth-jwt" ] || fail "ext-auth-jwt package output missing" @@ -35,13 +37,15 @@ fail() { (cd "$ROOT_DIR/npm/extensions/ext-bundler-esbuild" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) (cd "$ROOT_DIR/npm/extensions/ext-content-mdx" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) (cd "$ROOT_DIR/npm/extensions/ext-css-tailwind" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) +(cd "$ROOT_DIR/npm/extensions/ext-dev-ui-react" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) +(cd "$ROOT_DIR/npm/extensions/ext-node-websocket-ws" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) (cd "$ROOT_DIR/npm/extensions/ext-parser-babel" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) (cd "$ROOT_DIR/npm/extensions/ext-yaml" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) (cd "$ROOT_DIR/npm/extensions/ext-auth-jwt" && npm pack --silent --pack-destination "$WORKDIR" >/dev/null) cd "$WORKDIR" npm init -y >/dev/null 2>&1 -npm install --no-fund --no-audit --silent --ignore-scripts ./veryfront-[0-9]*.tgz ./veryfront-ext-bundler-esbuild-*.tgz ./veryfront-ext-content-mdx-*.tgz ./veryfront-ext-css-tailwind-*.tgz ./veryfront-ext-parser-babel-*.tgz ./veryfront-ext-yaml-*.tgz +npm install --no-fund --no-audit --silent --ignore-scripts ./veryfront-[0-9]*.tgz ./veryfront-ext-bundler-esbuild-*.tgz ./veryfront-ext-content-mdx-*.tgz ./veryfront-ext-css-tailwind-*.tgz ./veryfront-ext-dev-ui-react-*.tgz ./veryfront-ext-node-websocket-ws-*.tgz ./veryfront-ext-parser-babel-*.tgz ./veryfront-ext-yaml-*.tgz echo "== 1. root install: CLI and parser extension run under Node" node node_modules/veryfront/bin/veryfront.js --version | grep -q "Veryfront CLI" || @@ -52,7 +56,9 @@ node -e " const p = require('./node_modules/veryfront/package.json'); if (p.dependencies?.['@veryfront/ext-parser-babel'] !== p.version) process.exit(1); if (p.dependencies?.['@veryfront/ext-yaml'] !== p.version) process.exit(1); -" || fail "root package does not pin required parser extensions to its version" +if (p.dependencies?.['@veryfront/ext-dev-ui-react'] !== p.version) process.exit(1); +if (p.dependencies?.['@veryfront/ext-node-websocket-ws'] !== p.version) process.exit(1); +" || fail "root package does not pin standard extensions to its version" node --input-type=module -e " const m = await import('./node_modules/veryfront/esm/src/extensions/builtin-extensions.js'); const { getDeferredExtensionState } = await import( diff --git a/src/extensions/builtin-extensions.test.ts b/src/extensions/builtin-extensions.test.ts index e3b462883a..155ac654cc 100644 --- a/src/extensions/builtin-extensions.test.ts +++ b/src/extensions/builtin-extensions.test.ts @@ -15,6 +15,7 @@ import { } from "./builtin-extensions.ts"; import { mergeExtensions } from "./discovery.ts"; import { getDeferredExtensionState } from "./deferred-extension.ts"; +import { FIRST_PARTY_EXTENSION_POLICIES } from "./first-party-defaults.ts"; import { createZodAdapter } from "@veryfront/ext-schema-zod"; import { ExtensionLoader } from "./loader.ts"; @@ -132,6 +133,15 @@ describe("createBuiltinExtensions", () => { ); }); + it("loads the generic HTTP eval exporter as a deferred builtin", async () => { + const httpExtension = await loadOptionalBuiltin("ext-eval-report-http"); + + assertEquals( + httpExtension.contracts?.requires?.includes(EvalReportExporterRegistryName), + true, + ); + }); + it("keeps optional candidates deferred until the loader selects them", () => { const authCandidate = createBuiltinExtensions().find((entry) => entry.extension.name === "ext-auth-jwt" @@ -141,16 +151,96 @@ describe("createBuiltinExtensions", () => { assert(getDeferredExtensionState(authCandidate)); }); - it("never auto-loads the explicit Node WebSocket implementation", async () => { - const source = await Deno.readTextFile(new URL("./builtin-extensions.ts", import.meta.url)); + it("ships baseline CSS and Node WebSocket providers as deferred builtins", () => { + for (const name of ["ext-css-tailwind", "ext-node-websocket-ws"]) { + const definition = OPTIONAL_BUILTIN_EXTENSIONS.find((entry) => entry.name === name); + const candidate = createBuiltinExtensions().find((entry) => entry.extension.name === name); + + assert(definition, `${name} must be part of the default runtime composition`); + assert(candidate, `${name} must have a builtin candidate`); + assert(getDeferredExtensionState(candidate), `${name} must remain lazy until activation`); + } + }); - assertEquals(source.includes("ext-node-websocket-ws"), false); + it("keeps builtin package discovery metadata auto-activated", async () => { + for (const definition of OPTIONAL_BUILTIN_EXTENSIONS) { + const manifest = JSON.parse( + await Deno.readTextFile( + new URL( + `../../extensions/${definition.sourceDirectory}/deno.json`, + import.meta.url, + ), + ), + ) as { veryfront?: { activation?: string } }; + + assertEquals( + manifest.veryfront?.activation ?? "auto", + "auto", + `${definition.name} cannot be both a builtin and explicit-only package`, + ); + } + }); + + it("classifies every first-party extension exactly once", async () => { + const extensionDirectories: string[] = []; + for await (const entry of Deno.readDir(new URL("../../extensions", import.meta.url))) { + if (!entry.isDirectory || !entry.name.startsWith("ext-")) continue; + try { + await Deno.stat( + new URL(`../../extensions/${entry.name}/deno.json`, import.meta.url), + ); + extensionDirectories.push(entry.name); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + } + + const policyDirectories = FIRST_PARTY_EXTENSION_POLICIES.map((policy) => + policy.sourceDirectory + ); assertEquals( - OPTIONAL_BUILTIN_EXTENSIONS.some((definition) => definition.name === "ext-node-websocket-ws"), - false, + [...new Set(policyDirectories)].sort(), + extensionDirectories.sort(), + "the activation policy must classify every first-party extension once", + ); + assertEquals( + new Set(FIRST_PARTY_EXTENSION_POLICIES.map((policy) => policy.name)).size, + FIRST_PARTY_EXTENSION_POLICIES.length, + "first-party extension names must be unique", ); }); + it("keeps discovery activation aligned with first-party selection policy", async () => { + for (const policy of FIRST_PARTY_EXTENSION_POLICIES) { + const manifest = JSON.parse( + await Deno.readTextFile( + new URL( + `../../extensions/${policy.sourceDirectory}/deno.json`, + import.meta.url, + ), + ), + ) as { veryfront?: { activation?: string } }; + const activation = manifest.veryfront?.activation ?? "auto"; + const expected = policy.selection === "explicit" || + policy.selection === "service-conditional" + ? "explicit" + : "auto"; + + assertEquals( + activation, + expected, + `${policy.name} manifest activation must match ${policy.selection}`, + ); + if (policy.rootNpm) { + assertEquals( + policy.selection, + "builtin-deferred", + `${policy.name} cannot be a root npm dependency without builtin selection`, + ); + } + } + }); + it("does not statically import workspace implementation paths", async () => { const source = await Deno.readTextFile(new URL("./builtin-extensions.ts", import.meta.url)); @@ -258,18 +348,25 @@ describe("createBuiltinExtensions", () => { await loader.teardownAll(); }); - it("declares explicit eval exporter ids for optional exporter builtins", () => { + it("declares eval CLI selectors for optional exporter builtins", () => { + const http = OPTIONAL_BUILTIN_EXTENSIONS.find((definition) => + definition.name === "ext-eval-report-http" + ); const mlflow = OPTIONAL_BUILTIN_EXTENSIONS.find((definition) => definition.name === "ext-eval-report-mlflow" ); - assertEquals(mlflow?.evalExporterId, "mlflow"); + assertEquals(http?.evalExporterSelection, { kind: "any-selected" }); + assertEquals(mlflow?.evalExporterSelection, { kind: "id", id: "mlflow" }); }); it("builds a minimal eval CLI builtin set for selected eval exporters", () => { - const names = createEvalCliBuiltinExtensions(["mlflow"]).map((entry) => entry.extension.name); + const names = createEvalCliBuiltinExtensions(["http", "mlflow"]).map((entry) => + entry.extension.name + ); assertEquals(names.includes("ext-schema-zod"), true); + assertEquals(names.includes("ext-eval-report-http"), true); assertEquals(names.includes("ext-eval-report-mlflow"), true); assertEquals(names.includes("ext-auth-jwt"), false); assertEquals(names.includes("ext-observability-opentelemetry"), false); @@ -278,7 +375,17 @@ describe("createBuiltinExtensions", () => { it("does not load optional eval exporter builtins when no exporters are selected", () => { const names = createEvalCliBuiltinExtensions([]).map((entry) => entry.extension.name); + assertEquals(names.includes("ext-eval-report-http"), false); assertEquals(names.includes("ext-eval-report-mlflow"), false); assertEquals(names.includes("ext-auth-jwt"), false); }); + + it("loads the configurable HTTP exporter for custom selected ids", () => { + const names = createEvalCliBuiltinExtensions(["internal-gateway"]).map((entry) => + entry.extension.name + ); + + assertEquals(names.includes("ext-eval-report-http"), true); + assertEquals(names.includes("ext-eval-report-mlflow"), false); + }); }); diff --git a/src/extensions/builtin-extensions.ts b/src/extensions/builtin-extensions.ts index 5f515c1243..4e591082bb 100644 --- a/src/extensions/builtin-extensions.ts +++ b/src/extensions/builtin-extensions.ts @@ -12,6 +12,10 @@ import { createDeferredResolvedExtension } from "./deferred-extension.ts"; import { captureRegistrationId } from "./runtime-validation.ts"; import type { LLMProvider, LLMProviderRegistry } from "./llm/index.ts"; import { createLLMProviderRegistry, LLMProviderRegistryName } from "./llm/index.ts"; +import { + FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES, + type FirstPartyEvalExporterSelection, +} from "./first-party-defaults.ts"; import { OpenAIProvider } from "@veryfront/ext-llm-openai"; import { AnthropicProvider } from "@veryfront/ext-llm-anthropic"; import { GoogleProvider } from "@veryfront/ext-llm-google"; @@ -29,7 +33,7 @@ export type OptionalBuiltinExtensionDefinition = { readonly name: string; readonly origin: string; readonly sourceDirectory: string; - readonly evalExporterId?: string; + readonly evalExporterSelection?: FirstPartyEvalExporterSelection; readonly factory?: ExtensionFactory; }; @@ -51,67 +55,25 @@ const BUILTIN_LLM_PROVIDERS: BuiltinLLMProviderDefinition[] = [ }, ]; -export const OPTIONAL_BUILTIN_EXTENSIONS = Object.freeze(([ - { - name: "ext-auth-jwt", - origin: "veryfront/ext-auth-jwt", - sourceDirectory: "ext-auth-jwt", - }, - { - name: "ext-observability-opentelemetry", - origin: "veryfront/ext-observability-opentelemetry", - sourceDirectory: "ext-observability-opentelemetry", - }, - { - name: "ext-bundler-esbuild", - origin: "veryfront/ext-bundler-esbuild", - sourceDirectory: "ext-bundler-esbuild", - }, - { - name: "ext-dev-ui-react", - origin: "veryfront/ext-dev-ui-react", - sourceDirectory: "ext-dev-ui-react", - }, - { - name: "ext-parser-babel", - origin: "veryfront/ext-parser-babel", - sourceDirectory: "ext-parser-babel", - }, - { - name: "ext-yaml", - origin: "veryfront/ext-yaml", - sourceDirectory: "ext-yaml", - }, - { - name: "ext-content-mdx", - origin: "veryfront/ext-content-mdx", - sourceDirectory: "ext-content-mdx", - }, - { - name: "ext-document-kreuzberg", - origin: "veryfront/ext-document-kreuzberg", - sourceDirectory: "ext-document-kreuzberg", - }, - { - name: "ext-db-sqlite", - origin: "veryfront/ext-db-sqlite", - sourceDirectory: "ext-db-sqlite", - }, - { - name: "ext-sandbox-shell-tools", - origin: "veryfront/ext-sandbox-shell-tools", - sourceDirectory: "ext-sandbox-shell-tools", - }, - { - name: "ext-eval-report-mlflow", - origin: "veryfront/ext-eval-report-mlflow", - sourceDirectory: "ext-eval-report-mlflow", - evalExporterId: "mlflow", - // MLflow is deliberately shipped inside the root npm package rather than - // published as a standalone extension package. - factory: extEvalReportMlflow, - }, -] satisfies OptionalBuiltinExtensionDefinition[]).map((definition) => Object.freeze(definition))); +export const OPTIONAL_BUILTIN_EXTENSIONS = Object.freeze( + FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES.map((policy) => + Object.freeze({ + name: policy.name, + origin: `veryfront/${policy.sourceDirectory}`, + sourceDirectory: policy.sourceDirectory, + ...(policy.evalExporterSelection + ? { evalExporterSelection: policy.evalExporterSelection } + : {}), + ...(policy.name === "ext-eval-report-mlflow" + ? { + // MLflow is deliberately shipped inside the root npm package rather + // than published as a standalone extension package. + factory: extEvalReportMlflow, + } + : {}), + }) satisfies OptionalBuiltinExtensionDefinition + ), +); function getOrCreateLLMProviderRegistry(): LLMProviderRegistry { const existing = tryResolve(LLMProviderRegistryName); @@ -299,10 +261,11 @@ export function createEvalCliBuiltinExtensions( selectedExporterIds: string[] = [], ): ResolvedExtension[] { const selected = new Set(selectedExporterIds); - const exporterExtensions = OPTIONAL_BUILTIN_EXTENSIONS.filter((definition) => - definition.evalExporterId !== undefined && - selected.has(definition.evalExporterId) - ); + const exporterExtensions = OPTIONAL_BUILTIN_EXTENSIONS.filter((definition) => { + const selection = definition.evalExporterSelection; + if (!selection) return false; + return selection.kind === "any-selected" ? selected.size > 0 : selected.has(selection.id); + }); return [ { diff --git a/src/extensions/first-party-defaults.ts b/src/extensions/first-party-defaults.ts new file mode 100644 index 0000000000..9f8009f861 --- /dev/null +++ b/src/extensions/first-party-defaults.ts @@ -0,0 +1,206 @@ +/** Selection policy for every first-party extension package. */ +export type FirstPartyExtensionSelection = + | "builtin-direct" + | "builtin-deferred" + | "service-conditional" + | "explicit"; + +export type FirstPartyEvalExporterSelection = Readonly< + | { readonly kind: "any-selected" } + | { readonly kind: "id"; readonly id: string } +>; + +export type FirstPartyExtensionPolicy = Readonly<{ + readonly name: string; + readonly sourceDirectory: string; + readonly selection: FirstPartyExtensionSelection; + readonly rootNpm: boolean; + readonly evalExporterSelection?: FirstPartyEvalExporterSelection; +}>; + +/** + * Authoritative inventory for first-party extension activation. + * + * `rootNpm` is intentionally narrower than builtin selection. Source builds + * and dedicated services can provide every builtin from their own package + * set, while the standard npm/CLI distribution installs only baseline app and + * developer-runtime capabilities. Credentialed, mutually exclusive, native, + * output-changing, and service-lifecycle integrations stay out of that root + * dependency set. + */ +export const FIRST_PARTY_EXTENSION_POLICIES = Object.freeze(([ + { + name: "ext-auth-jwt", + sourceDirectory: "ext-auth-jwt", + selection: "builtin-deferred", + rootNpm: false, + }, + { + name: "ext-blob-gcs", + sourceDirectory: "ext-blob-gcs", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-blob-s3", + sourceDirectory: "ext-blob-s3", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-bundler-esbuild", + sourceDirectory: "ext-bundler-esbuild", + selection: "builtin-deferred", + rootNpm: true, + }, + { + name: "ext-cache-redis", + sourceDirectory: "ext-cache-redis", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-content-mdx", + sourceDirectory: "ext-content-mdx", + selection: "builtin-deferred", + rootNpm: true, + }, + { + name: "ext-css-lightning", + sourceDirectory: "ext-css-lightning", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-css-purgecss", + sourceDirectory: "ext-css-purgecss", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-css-tailwind", + sourceDirectory: "ext-css-tailwind", + selection: "builtin-deferred", + rootNpm: true, + }, + { + name: "ext-db-sqlite", + sourceDirectory: "ext-db-sqlite", + selection: "builtin-deferred", + rootNpm: false, + }, + { + name: "ext-dev-ui-react", + sourceDirectory: "ext-dev-ui-react", + selection: "builtin-deferred", + rootNpm: true, + }, + { + name: "ext-document-kreuzberg", + sourceDirectory: "ext-document-kreuzberg", + selection: "builtin-deferred", + rootNpm: false, + }, + { + name: "ext-eval-report-http", + sourceDirectory: "ext-eval-report-http", + selection: "builtin-deferred", + rootNpm: false, + evalExporterSelection: Object.freeze({ kind: "any-selected" }), + }, + { + name: "ext-eval-report-mlflow", + sourceDirectory: "ext-eval-report-mlflow", + selection: "builtin-deferred", + rootNpm: false, + evalExporterSelection: Object.freeze({ kind: "id", id: "mlflow" }), + }, + { + name: "ext-image-sharp", + sourceDirectory: "ext-image-sharp", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-llm-anthropic", + sourceDirectory: "ext-llm-anthropic", + selection: "builtin-direct", + rootNpm: false, + }, + { + name: "ext-llm-google", + sourceDirectory: "ext-llm-google", + selection: "builtin-direct", + rootNpm: false, + }, + { + name: "ext-llm-openai", + sourceDirectory: "ext-llm-openai", + selection: "builtin-direct", + rootNpm: false, + }, + { + name: "ext-node-websocket-ws", + sourceDirectory: "ext-node-websocket-ws", + selection: "builtin-deferred", + rootNpm: true, + }, + { + name: "ext-observability-opentelemetry", + sourceDirectory: "ext-observability-opentelemetry", + selection: "builtin-deferred", + rootNpm: false, + }, + { + name: "ext-observability-sentry", + sourceDirectory: "ext-observability-sentry", + selection: "service-conditional", + rootNpm: false, + }, + { + name: "ext-parser-babel", + sourceDirectory: "ext-parser-babel", + selection: "builtin-deferred", + rootNpm: true, + }, + { + name: "ext-react-ssr", + sourceDirectory: "ext-react-ssr", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-redis", + sourceDirectory: "ext-redis", + selection: "explicit", + rootNpm: false, + }, + { + name: "ext-sandbox-shell-tools", + sourceDirectory: "ext-sandbox-shell-tools", + selection: "builtin-deferred", + rootNpm: false, + }, + { + name: "ext-schema-zod", + sourceDirectory: "ext-schema-zod", + selection: "builtin-direct", + rootNpm: false, + }, + { + name: "ext-yaml", + sourceDirectory: "ext-yaml", + selection: "builtin-deferred", + rootNpm: true, + }, +] satisfies FirstPartyExtensionPolicy[]).map((policy) => Object.freeze(policy))); + +export const FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES = Object.freeze( + FIRST_PARTY_EXTENSION_POLICIES.filter((policy) => policy.selection === "builtin-deferred"), +); + +export const STANDARD_ROOT_NPM_EXTENSION_DIRECTORIES = Object.freeze( + FIRST_PARTY_EXTENSION_POLICIES.filter((policy) => policy.rootNpm).map( + (policy) => policy.sourceDirectory, + ), +); diff --git a/src/extensions/orchestrate.test.ts b/src/extensions/orchestrate.test.ts index 02fb329c8f..e4cae07cad 100644 --- a/src/extensions/orchestrate.test.ts +++ b/src/extensions/orchestrate.test.ts @@ -13,7 +13,11 @@ import { reset, resolve as resolveContract, tryResolve } from "./contracts.ts"; import type { Extension, ExtensionSource, ResolvedExtension } from "./types.ts"; import type { LLMProvider, LLMProviderRegistry } from "./llm/index.ts"; import { createLLMProviderRegistry, LLMProviderRegistryName } from "./llm/index.ts"; -import { createBuiltinExtensions } from "./builtin-extensions.ts"; +import { + createBuiltinExtensions, + createEvalCliBuiltinExtensions, + createOptionalBuiltinExtension, +} from "./builtin-extensions.ts"; import { join } from "@std/path"; const noopLogger = { @@ -609,6 +613,164 @@ describe("orchestrateExtensions()", () => { await loader.teardownAll(); }); + it("keeps installed first-party builtin packages deferred and prefilters their disable aliases", async () => { + const packageHits = [ + { + packageName: "@veryfront/ext-css-tailwind", + importTarget: "/canonical/ext-css-tailwind.js", + metadata: { + isExtension: true as const, + activation: "auto" as const, + capabilities: [], + }, + }, + { + packageName: "@veryfront/ext-node-websocket-ws", + importTarget: "/canonical/ext-node-websocket-ws.js", + metadata: { + isExtension: true as const, + activation: "auto" as const, + capabilities: [], + }, + }, + ]; + + for ( + const [disabledName, expectedDeferredFactoryCalls] of [ + ["ext-css-tailwind", ["ext-node-websocket-ws"]], + ["@veryfront/ext-node-websocket-ws", ["ext-css-tailwind"]], + ] as const + ) { + const loadCalls: string[] = []; + const deferredFactoryCalls: string[] = []; + const loader = await orchestrateExtensions({ + projectDir: "/fake", + config: { + extensions: [{ name: disabledName, enabled: false }], + }, + logger: noopLogger, + discovery: { + ...emptyDiscovery(), + discoverPackageExtensions: () => Promise.resolve(packageHits), + }, + builtinExtensions: [ + createOptionalBuiltinExtension({ + name: "ext-css-tailwind", + origin: "veryfront/ext-css-tailwind", + sourceDirectory: "ext-css-tailwind", + factory: () => { + deferredFactoryCalls.push("ext-css-tailwind"); + return stubExt("ext-css-tailwind", { + contracts: { provides: ["CSSProcessor"] }, + setup: (ctx) => ctx.provide("CSSProcessor", { id: "tailwind" }), + }); + }, + }), + createOptionalBuiltinExtension({ + name: "ext-node-websocket-ws", + origin: "veryfront/ext-node-websocket-ws", + sourceDirectory: "ext-node-websocket-ws", + factory: () => { + deferredFactoryCalls.push("ext-node-websocket-ws"); + return stubExt("ext-node-websocket-ws", { + contracts: { provides: ["NodeWebSocketServerProvider"] }, + setup: (ctx) => ctx.provide("NodeWebSocketServerProvider", { id: "ws" }), + }); + }, + }), + ], + loadFactory: (path: string, source: ExtensionSource) => { + loadCalls.push(path); + return Promise.resolve({ + extension: stubExt(path), + source, + origin: path, + }); + }, + }); + + assertEquals(loadCalls, []); + assertEquals(deferredFactoryCalls, [...expectedDeferredFactoryCalls]); + await loader.teardownAll(); + } + }); + + it("keeps package discovery above ordinary builtins with the same first-party name", async () => { + const loader = await orchestrateExtensions({ + projectDir: "/fake", + config: {}, + logger: noopLogger, + discovery: { + ...emptyDiscovery(), + discoverPackageExtensions: () => + Promise.resolve([{ + packageName: "@veryfront/ext-css-tailwind", + importTarget: "/canonical/ext-css-tailwind.js", + metadata: { + isExtension: true as const, + activation: "auto" as const, + capabilities: [], + }, + }]), + }, + builtinExtensions: [{ + extension: stubExt("ext-css-tailwind", { + provides: { SelectedExtensionSource: { from: "builtin" } }, + }), + source: "builtin", + origin: "custom-direct-builtin", + }], + loadFactory: (_path: string, source: ExtensionSource) => + Promise.resolve({ + extension: stubExt("ext-css-tailwind", { + provides: { SelectedExtensionSource: { from: "package" } }, + }), + source, + origin: "canonical-package", + }), + }); + + assertEquals(tryResolve("SelectedExtensionSource"), { from: "package" }); + await loader.teardownAll(); + }); + + it("keeps deferred packages lazy for the reduced eval CLI builtin set", async () => { + const loadCalls: string[] = []; + const loader = await orchestrateExtensions({ + projectDir: "/fake", + config: {}, + logger: noopLogger, + primeContracts: { + [LLMProviderRegistryName]: createLLMProviderRegistry(), + }, + discovery: { + ...emptyDiscovery(), + discoverPackageExtensions: () => + Promise.resolve([{ + packageName: "@veryfront/ext-css-tailwind", + importTarget: "/canonical/ext-css-tailwind.js", + metadata: { + isExtension: true as const, + activation: "auto" as const, + capabilities: [], + }, + }]), + }, + builtinExtensions: createEvalCliBuiltinExtensions([]), + loadFactory: (path: string, source: ExtensionSource) => { + loadCalls.push(path); + return Promise.resolve({ + extension: stubExt("ext-css-tailwind"), + source, + origin: path, + }); + }, + }); + + assertEquals(loadCalls, []); + await loader.teardownAll(); + }); + it("skips loadFactory for disabled project extensions (src/index.ts variant)", async () => { const loadCalls: string[] = []; diff --git a/src/extensions/orchestrate.ts b/src/extensions/orchestrate.ts index 3ee1ef98b2..0e17156655 100644 --- a/src/extensions/orchestrate.ts +++ b/src/extensions/orchestrate.ts @@ -9,9 +9,11 @@ */ import { basename, dirname } from "#veryfront/compat/path"; +import { getDeferredExtensionState } from "./deferred-extension.ts"; import * as defaultDiscovery from "./discovery.ts"; import type { BoundExtensionEntrypoint } from "./entrypoint-identity.ts"; import { loadExtensionFactory as defaultLoadFactory } from "./factory-loader.ts"; +import { FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES } from "./first-party-defaults.ts"; import { ExtensionLoader } from "./loader.ts"; import type { Extension, @@ -69,6 +71,18 @@ export interface OrchestrateOptions { let orchestrationTail: Promise = Promise.resolve(); let activeLoader: ExtensionLoader | undefined; let failedCandidate: ExtensionLoader | undefined; +const FIRST_PARTY_BUILTIN_PACKAGE_TO_EXTENSION = new Map( + FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES.map((policy) => [ + `@veryfront/${policy.sourceDirectory}`, + policy.name, + ]), +); +const FIRST_PARTY_BUILTIN_EXTENSION_TO_PACKAGE = new Map( + FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES.map((policy) => [ + policy.name, + `@veryfront/${policy.sourceDirectory}`, + ]), +); function isDisableDirective( entry: ExtensionConfigEntry, @@ -117,6 +131,30 @@ interface ProjectLoadCandidate { target: FactoryLoadTarget; } +function buildDisableFilters( + disables: Array<{ name: string; enabled: false }>, +): { extensionNames: Set; packageNames: Set } { + const extensionNames = new Set(); + const packageNames = new Set(); + for (const { name } of disables) { + extensionNames.add(name); + packageNames.add(name); + const extensionName = FIRST_PARTY_BUILTIN_PACKAGE_TO_EXTENSION.get(name); + if (extensionName) extensionNames.add(extensionName); + const packageName = FIRST_PARTY_BUILTIN_EXTENSION_TO_PACKAGE.get(name); + if (packageName) packageNames.add(packageName); + } + return { extensionNames, packageNames }; +} + +function isDeferredBuiltinPackageHit( + hit: PackageLoadCandidate, + ordinaryBuiltinExtensionNames: ReadonlySet, +): boolean { + const extensionName = FIRST_PARTY_BUILTIN_PACKAGE_TO_EXTENSION.get(hit.packageName); + return extensionName !== undefined && !ordinaryBuiltinExtensionNames.has(extensionName); +} + /** * Run the full extension pipeline against a resolved project config. * @@ -173,7 +211,15 @@ async function orchestrateExtensionGeneration( // extensions the user has explicitly turned off. A factory whose module // fails to import or invoke would otherwise take down bootstrap even // though the user asked for it to be disabled. - const disabledNames = new Set(disables.map((d) => d.name)); + const disabled = buildDisableFilters(disables); + // First-party deferred packages stay lazy even when a reduced caller omits + // their candidate. Ordinary builtins are exempt so package discovery keeps + // its documented priority over direct builtin entries with the same name. + const ordinaryBuiltinExtensionNames = new Set( + (options.builtinExtensions ?? []) + .filter((entry) => getDeferredExtensionState(entry) === undefined) + .map((entry) => entry.extension.name), + ); let packageHits: PackageLoadCandidate[]; let projectHits: ProjectLoadCandidate[]; @@ -217,14 +263,20 @@ async function orchestrateExtensionGeneration( // This prevents an import map from redirecting the authorized package name. const enabledPackageTargets = packageHits .filter((hit) => defaultDiscovery.resolvePackageActivation(hit.metadata) === "auto") - .filter((hit) => !disabledNames.has(hit.packageName)) + .filter((hit) => + !disabled.packageNames.has(hit.packageName) && + !disabled.extensionNames.has(hit.packageName) && + !isDeferredBuiltinPackageHit(hit, ordinaryBuiltinExtensionNames) + ) .map((hit) => hit.target); // Project paths have the shape `/extensions//src/index.ts` // (or `/extensions//index.ts`). `mergeExtensions` is the // safety net for any path whose name cannot be derived. const enabledProjectTargets = projectHits - .filter((hit) => hit.extensionName === undefined || !disabledNames.has(hit.extensionName)) + .filter((hit) => + hit.extensionName === undefined || !disabled.extensionNames.has(hit.extensionName) + ) .map((hit) => hit.target); // Local-file paths cannot be reliably filtered pre-load: the filename @@ -251,7 +303,7 @@ async function orchestrateExtensionGeneration( packageResolved, projectResolved, localResolved, - disables, + [...disabled.extensionNames].map((name) => ({ name, enabled: false as const })), options.builtinExtensions, ); diff --git a/src/extensions/websocket/node-websocket-server-provider.test.ts b/src/extensions/websocket/node-websocket-server-provider.test.ts index df320226b2..f2c086cfe7 100644 --- a/src/extensions/websocket/node-websocket-server-provider.test.ts +++ b/src/extensions/websocket/node-websocket-server-provider.test.ts @@ -125,6 +125,6 @@ Deno.test("Node WebSocket provider helper and missing-contract diagnostic are ac ); assertStringIncludes( NODE_WEBSOCKET_SERVER_PROVIDER_MISSING_MESSAGE, - "explicitly enabled", + "install it or remove the extension disable directive", ); }); diff --git a/src/extensions/websocket/node-websocket-server-provider.ts b/src/extensions/websocket/node-websocket-server-provider.ts index 4b3b8a8f12..48a63277ba 100644 --- a/src/extensions/websocket/node-websocket-server-provider.ts +++ b/src/extensions/websocket/node-websocket-server-provider.ts @@ -1,9 +1,9 @@ /** * Dependency-free contract for Node.js WebSocket server implementations. * - * Core owns HTTP upgrade authorization and lifecycle. An explicitly composed - * extension owns the protocol implementation used to complete an authorized - * upgrade on an existing Node socket. + * Core owns HTTP upgrade authorization and lifecycle. An extension owns the + * protocol implementation used to complete an authorized upgrade on an + * existing Node socket. */ import { types as nodeUtilTypes } from "node:util"; @@ -11,7 +11,7 @@ import { types as nodeUtilTypes } from "node:util"; export const NodeWebSocketServerProviderName = "NodeWebSocketServerProvider"; export const NODE_WEBSOCKET_SERVER_PROVIDER_PACKAGE = "@veryfront/ext-node-websocket-ws"; export const NODE_WEBSOCKET_SERVER_PROVIDER_MISSING_MESSAGE = - `Node.js WebSocket upgrades require an explicitly enabled ${NODE_WEBSOCKET_SERVER_PROVIDER_PACKAGE} extension.`; + `Node.js WebSocket upgrades require ${NODE_WEBSOCKET_SERVER_PROVIDER_PACKAGE}; install it or remove the extension disable directive.`; const isProxy = nodeUtilTypes.isProxy; diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 535fe3b384..0c753fb85f 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -166,7 +166,7 @@ function createBootstrapPrimeContracts(): Record { }; } -/** @internal Snapshot the explicit Node WebSocket implementation for this generation. */ +/** @internal Snapshot the extension-provided Node WebSocket implementation for this generation. */ export function resolveNodeWebSocketServerProviderForBootstrap(): | Readonly | undefined { diff --git a/src/server/dev-server/server.ts b/src/server/dev-server/server.ts index 552a0c4a71..72bb0a853b 100644 --- a/src/server/dev-server/server.ts +++ b/src/server/dev-server/server.ts @@ -286,7 +286,7 @@ export class DevServer { return this._handler; } - /** Explicit Node WebSocket implementation captured with this bootstrap generation. */ + /** Extension-provided Node WebSocket implementation captured with this bootstrap generation. */ get nodeWebSocketServerProvider(): Readonly | undefined { return this._nodeWebSocketServerProvider; } diff --git a/src/server/service-server.ts b/src/server/service-server.ts index e1d0649fa6..49102326c8 100644 --- a/src/server/service-server.ts +++ b/src/server/service-server.ts @@ -51,7 +51,7 @@ export type StartNodeVeryfrontServerOptions = { logger?: VeryfrontServiceServerLogger; signals?: readonly NodeJS.Signals[]; hardShutdownTimeoutMs?: number; - /** Explicit Node WebSocket implementation; upgrades fail closed when omitted. */ + /** Extension-provided Node WebSocket implementation; upgrades fail closed when omitted. */ nodeWebSocketServerProvider?: Readonly; };