diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index e5904a3958..a26cbd9faf 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -177,8 +177,8 @@ Result returned from use agent. | `ConversationScrollButton` | Render conversation scroll button. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L116) | | `ConversationsProvider` | ConversationsProvider - calls `useConversations` once with your `store` / `id` / `onSelect` and shares it via `ConversationsContext`. Declare persistence + router wiring here, once, at the app layout; children read it with `useConversationsContext`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/conversations-context.tsx#L58) | | `CopyButton` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L191) | -| `DEFAULT_CHAT_STREAM_IDLE_TIMEOUT_MS` | Default value for chat stream idle timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L7) | -| `DEFAULT_CHAT_STREAM_TOOL_RUNNING_TIMEOUT_MS` | Default value for chat stream tool running timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L9) | +| `DEFAULT_CHAT_STREAM_IDLE_TIMEOUT_MS` | Default value for chat stream idle timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L13) | +| `DEFAULT_CHAT_STREAM_TOOL_RUNNING_TIMEOUT_MS` | Default value for chat stream tool running timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L15) | | `DropZoneOverlay` | Drag overlay shown over the composer while files are dragged onto it - the glyph-in-a-circle + "Drop files" from Studio's `PromptForm`. Rendered inside a `relative` card; fills it and blurs the content behind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/drop-zone.tsx#L18) | | `ErrorBanner` | Render error banner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/error-banner.tsx#L28) | | `FadeIn` | Render fade in. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/animations.tsx#L37) | @@ -215,19 +215,19 @@ Result returned from use agent. | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `agentsToPickerOptions` | Narrow browser-safe agent metadata to the picker's row shape. `AgentOption` now shares `AgentMetadata`'s `avatarUrl` field, so `AgentMetadata[]` is also accepted by `` directly - this helper just drops the fields the rows don't use. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-agent-picker.tsx#L32) | | `buildChatStreamChunkMessageMetadata` | Builds chat stream chunk message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/chat-ui-message-helpers.ts#L308) | -| `createChatStreamWatchdog` | Create chat stream watchdog backed by the lifecycle deadline primitive. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L280) | -| `createChatStreamWatchdogState` | State for create chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L67) | +| `createChatStreamWatchdog` | Create chat stream watchdog backed by the lifecycle deadline primitive. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L286) | +| `createChatStreamWatchdogState` | State for create chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L73) | | `dedupeChatUiMessageChunks` | Dedupe chat UI message chunks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/chat-ui-message-helpers.ts#L363) | | `downloadMarkdown` | Download messages as a .md file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/export.ts#L64) | | `exportAsMarkdown` | Convert chat messages to a markdown string. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/export.ts#L12) | | `extractChatMessageMetadata` | Extract chat message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/chat-ui-message-helpers.ts#L302) | | `extractSourcesFromParts` | Extract sources from native citations and tool result parts. Native source parts map directly, while tool outputs may expose a `documents` array. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/message-parts.ts#L164) | | `getAgentPromptSuggestions` | Return prompt text suggestions that the current Chat component can render. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-agent-metadata.ts#L161) | -| `getNextChatStreamWatchdogState` | State for get next chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L108) | +| `getNextChatStreamWatchdogState` | State for get next chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L114) | | `getTextContent` | Get text content from chat message parts | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/message-parts.ts#L16) | | `groupPartsInOrder` | Group consecutive parts for ordered rendering Returns an array of groups, each containing either consecutive text parts, a tool part, or a reasoning part | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/message-parts.ts#L102) | -| `isHeartbeatOnlyMetadataChunk` | Check whether a chunk only carries heartbeat metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L198) | -| `isLongRunningToolRunning` | Compatibility helper. Under strict lifecycle deadlines long-running tool names no longer disable the absolute tool-running deadline; in legacy mode they still do. Also exported for callers that classify tool activity. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L94) | +| `isHeartbeatOnlyMetadataChunk` | Check whether a chunk only carries heartbeat metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L204) | +| `isLongRunningToolRunning` | Compatibility helper. Under strict lifecycle deadlines long-running tool names no longer disable the absolute tool-running deadline; in legacy mode they still do. Also exported for callers that classify tool activity. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L100) | | `isReasoningPart` | Check if a part is a reasoning part | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/message-parts.ts#L82) | | `isSkillToolPart` | Check if a tool part is a skill-related tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/message-parts.ts#L77) | | `isToolPart` | Check if a part is a tool part | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/utils/message-parts.ts#L32) | @@ -271,7 +271,7 @@ Result returned from use agent. | Name | Description | Source | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `ChatErrorBoundary` | Implement chat error boundary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/error-boundary.tsx#L17) | -| `ChatStreamIdleTimeoutError` | Error shape for chat stream idle timeout. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L49) | +| `ChatStreamIdleTimeoutError` | Error shape for chat stream idle timeout. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L55) | | `ConversationStoreError` | Normalized persistence failure. Store implementations should reject rather than resolve when an operation did not complete; the React hooks wrap custom adapter rejections in this error so consumers can branch on `operation` without parsing an error message. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/persistence/conversation-store.ts#L31) | ### Types @@ -375,9 +375,9 @@ Result returned from use agent. | `ChatSidebarRootProps` | Props accepted by `ChatSidebarRoot`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.types.ts#L49) | | `ChatStepPart` | Public API contract for chat step part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L104) | | `ChatStreamEvent` | Event emitted for chat stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L221) | -| `ChatStreamWatchdogOptions` | Options accepted by chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L27) | -| `ChatStreamWatchdogPhase` | Public API contract for chat stream watchdog phase. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L12) | -| `ChatStreamWatchdogState` | State for chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L19) | +| `ChatStreamWatchdogOptions` | Options accepted by chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L33) | +| `ChatStreamWatchdogPhase` | Public API contract for chat stream watchdog phase. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L18) | +| `ChatStreamWatchdogState` | State for chat stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L25) | | `ChatTab` | Public API contract for chat tab. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/tab-switcher.tsx#L13) | | `ChatTextPart` | Chat message part that carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L11) | | `ChatTheme` | Public API contract for chat theme. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/theme.ts#L22) | diff --git a/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts b/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts index 3fb7f158cc..2473561140 100644 --- a/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts +++ b/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts @@ -1,5 +1,11 @@ import type { ChatUiMessageChunk, MessageMetadata } from "#veryfront/chat/types.ts"; -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +// Import from process/env.ts, not the process.ts barrel. This module is +// reachable from the `veryfront/chat` client entry, and the barrel also +// re-exports runCommand from process/command.ts, which pulls +// platform/compat/dynamic-import.ts and its `new Function` into the client +// bundle. Project pages ship a CSP without 'unsafe-eval', so that throws +// EvalError and kills hydration before first paint. +import { getHostEnv } from "#veryfront/platform/compat/process/env.ts"; import { type AbsoluteDeadlineTimer, createAbsoluteDeadline } from "./deadlines.ts"; import type { StreamLifecyclePhase } from "./types.ts"; diff --git a/src/platform/compat/dynamic-import.test.ts b/src/platform/compat/dynamic-import.test.ts index 5383cc8268..153cd68deb 100644 --- a/src/platform/compat/dynamic-import.test.ts +++ b/src/platform/compat/dynamic-import.test.ts @@ -19,4 +19,134 @@ describe("platform/compat/dynamic-import", () => { () => dynamicImport("__nonexistent_module_12345__"), ); }); + + /** + * This helper must stay OUT of client bundles. + * + * Its `new Function` is load-bearing on the server: it keeps the import + * non-literal so neither `deno compile` nor the release-asset rewriter + * traces into the specifier. But project pages ship + * `script-src 'self' 'nonce-...' https://esm.sh` with no 'unsafe-eval', so + * reaching it from a client entry throws EvalError and kills hydration + * before first paint. + * + * Removing the `new Function` is NOT the fix — the release-asset builder + * then rejects the module with "Release module contains a non-literal + * dynamic import". Keep the eval, keep it off the client. + */ + describe("client bundle reachability", () => { + const SRC_ROOT = new URL("../../", import.meta.url); + + /** Client entries behind PLATFORM_UTILITY_PATHS (src/html/utils.ts). */ + const CLIENT_ENTRIES = [ + "react/runtime/core.ts", + "react/fonts/index.ts", + "chat/index.ts", + "markdown/index.ts", + "mdx/index.ts", + "workflow/react/index.ts", + ]; + + function read(relPath: string): string | null { + try { + return Deno.readTextFileSync(new URL(relPath, SRC_ROOT)); + } catch { + return null; + } + } + + function resolveSpecifier(specifier: string, fromRel: string): string | null { + let base: string; + if (specifier.startsWith("#veryfront/")) { + base = specifier.slice("#veryfront/".length); + } else if (specifier.startsWith(".")) { + const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : ""; + base = new URL(specifier, `file:///${dir}/`).pathname.replace(/^\/+/, ""); + } else { + return null; + } + // index.tsx matters as much as index.ts here: the client entries walk + // React subtrees, and src/react/components/chat/chat/index.tsx is a + // directory module inside the one entry that actually leaked. Omitting + // it would make the traversal stop early and silently pass. + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}/index.ts`, + `${base}/index.tsx`, + ]; + for (const candidate of candidates) { + if (read(candidate) !== null) return candidate; + } + return null; + } + + /** Value imports only — `import type` is erased and never ships. */ + function valueImports(source: string): string[] { + const specifiers: string[] = []; + const re = /(?:^|\n)\s*(?:import|export)(\s+type)?\s*([\s\S]*?)from\s*["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = re.exec(source)) !== null) { + if (match[1]) continue; + const names = (match[2] ?? "").replace(/[{}]/g, "").split(",").map((n) => n.trim()) + .filter(Boolean); + if (names.length > 0 && names.every((n) => n.startsWith("type "))) continue; + const specifier = match[3]; + if (specifier !== undefined) specifiers.push(specifier); + } + const bare = /(?:^|\n)\s*import\s*["']([^"']+)["']/g; + while ((match = bare.exec(source)) !== null) { + const specifier = match[1]; + if (specifier !== undefined) specifiers.push(specifier); + } + return specifiers; + } + + const TARGET = "platform/compat/dynamic-import.ts"; + + for (const entry of CLIENT_ENTRIES) { + it(`should not be reachable from ${entry}`, () => { + const parent = new Map([[entry, null]]); + const queue = [entry]; + let found: string | null = null; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current === TARGET) { + found = current; + break; + } + const source = read(current); + if (source === null) continue; + for (const specifier of valueImports(source)) { + const resolved = resolveSpecifier(specifier, current); + if (resolved !== null && !parent.has(resolved)) { + parent.set(resolved, current); + queue.push(resolved); + } + } + } + + let chain = ""; + if (found !== null) { + const path: string[] = []; + let cursor: string | null = found; + while (cursor !== null) { + path.unshift(cursor); + cursor = parent.get(cursor) ?? null; + } + chain = `\n ${path.join("\n -> ")}`; + } + + assertEquals( + found, + null, + `client entry ${entry} value-imports dynamic-import.ts, whose new Function ` + + `the page CSP blocks at runtime — hydration dies before first paint.` + + `\nUsually a barrel import; import the defining module directly instead.${chain}`, + ); + }); + } + }); }); diff --git a/src/platform/environment.ts b/src/platform/environment.ts index 20f7eb9697..7837d50d3f 100644 --- a/src/platform/environment.ts +++ b/src/platform/environment.ts @@ -8,7 +8,13 @@ * @module platform/environment */ -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +// Import from process/env.ts, not the process.ts barrel. This module is +// reachable from the `veryfront/chat` client entry, and the barrel also +// re-exports runCommand from process/command.ts, which pulls +// platform/compat/dynamic-import.ts and its `new Function` into the client +// bundle. Project pages ship a CSP without 'unsafe-eval', so that throws +// EvalError and kills hydration before first paint. +import { getHostEnv } from "#veryfront/platform/compat/process/env.ts"; export type Environment = "development" | "production" | "test"; diff --git a/src/release-assets/scaffolded-project-build.test.ts b/src/release-assets/scaffolded-project-build.test.ts index f589035202..6ed99c9bce 100644 --- a/src/release-assets/scaffolded-project-build.test.ts +++ b/src/release-assets/scaffolded-project-build.test.ts @@ -260,6 +260,29 @@ describe("release assets: scaffolded project build", () => { assertEquals(result.state, "ready"); assertEquals(rec.states.map(({ state }) => state), []); + // Project pages are served with `script-src 'self' 'nonce-...' + // https://esm.sh` — no 'unsafe-eval'. A published JS asset containing + // `new Function` throws EvalError in the browser and kills hydration + // before first paint, leaving the page on its skeleton loaders. + // + // This asserts the shipped bytes, not the import graph, because the + // build succeeds either way: before the fix these scaffolds published + // platform/compat/dynamic-import.ts as its own chunk and every test here + // still passed. + const evalOffenders = rec.uploads + .filter((upload) => upload.contentType.includes("javascript")) + .filter((upload) => /\bnew Function\s*\(/.test(new TextDecoder().decode(upload.bytes))); + assertEquals( + evalOffenders.map((upload) => upload.hash), + [], + `${templateName} published JS assets containing new Function, which the page CSP ` + + `blocks at runtime: ${ + evalOffenders + .map((upload) => new TextDecoder().decode(upload.bytes).slice(0, 120)) + .join(" | ") + }`, + ); + const manifest = parseReleaseAssetManifest(rec.manifest); assertExists(manifest); assertEquals(