veryfront 0.1.72 → 0.1.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/esm/cli/commands/knowledge/command-help.d.ts.map +1 -1
  2. package/esm/cli/commands/knowledge/command-help.js +3 -1
  3. package/esm/cli/commands/knowledge/command.d.ts +32 -5
  4. package/esm/cli/commands/knowledge/command.d.ts.map +1 -1
  5. package/esm/cli/commands/knowledge/command.js +87 -21
  6. package/esm/cli/commands/knowledge/parser-source.d.ts.map +1 -1
  7. package/esm/cli/commands/knowledge/parser-source.js +110 -5
  8. package/esm/deno.js +1 -1
  9. package/esm/src/html/html-shell-generator.d.ts.map +1 -1
  10. package/esm/src/html/html-shell-generator.js +6 -0
  11. package/esm/src/rendering/orchestrator/pipeline.d.ts.map +1 -1
  12. package/esm/src/rendering/orchestrator/pipeline.js +116 -105
  13. package/esm/src/server/dev-server/error-overlay/error-formatter.d.ts +4 -0
  14. package/esm/src/server/dev-server/error-overlay/error-formatter.d.ts.map +1 -1
  15. package/esm/src/server/dev-server/error-overlay/error-formatter.js +15 -0
  16. package/esm/src/server/dev-server/error-overlay/html-template.d.ts +1 -1
  17. package/esm/src/server/dev-server/error-overlay/html-template.d.ts.map +1 -1
  18. package/esm/src/server/dev-server/error-overlay/html-template.js +131 -8
  19. package/esm/src/server/dev-server/error-overlay/index.d.ts +1 -1
  20. package/esm/src/server/dev-server/error-overlay/index.d.ts.map +1 -1
  21. package/esm/src/server/dev-server/error-overlay/index.js +1 -1
  22. package/esm/src/server/dev-server/error-overlay/overlay-renderer.d.ts +1 -1
  23. package/esm/src/server/dev-server/error-overlay/overlay-renderer.d.ts.map +1 -1
  24. package/esm/src/server/dev-server/error-overlay/overlay-renderer.js +2 -2
  25. package/esm/src/server/dev-server/request-handler.d.ts.map +1 -1
  26. package/esm/src/server/dev-server/request-handler.js +6 -2
  27. package/esm/src/server/handlers/request/ssr/ssr.handler.d.ts +2 -0
  28. package/esm/src/server/handlers/request/ssr/ssr.handler.d.ts.map +1 -1
  29. package/esm/src/server/handlers/request/ssr/ssr.handler.js +6 -2
  30. package/esm/src/server/runtime-handler/adapter-factory.d.ts +3 -0
  31. package/esm/src/server/runtime-handler/adapter-factory.d.ts.map +1 -1
  32. package/esm/src/server/runtime-handler/adapter-factory.js +6 -5
  33. package/esm/src/server/runtime-handler/index.d.ts +33 -0
  34. package/esm/src/server/runtime-handler/index.d.ts.map +1 -1
  35. package/esm/src/server/runtime-handler/index.js +103 -37
  36. package/esm/src/server/runtime-handler/local-project-discovery.d.ts +32 -4
  37. package/esm/src/server/runtime-handler/local-project-discovery.d.ts.map +1 -1
  38. package/esm/src/server/runtime-handler/local-project-discovery.js +46 -16
  39. package/esm/src/server/services/rendering/ssr.service.d.ts +19 -1
  40. package/esm/src/server/services/rendering/ssr.service.d.ts.map +1 -1
  41. package/esm/src/server/services/rendering/ssr.service.js +18 -3
  42. package/esm/src/server/shared/renderer/adapter.d.ts +25 -0
  43. package/esm/src/server/shared/renderer/adapter.d.ts.map +1 -1
  44. package/esm/src/server/shared/renderer/adapter.js +83 -10
  45. package/esm/src/server/shared/renderer/index.d.ts +1 -1
  46. package/esm/src/server/shared/renderer/index.d.ts.map +1 -1
  47. package/esm/src/server/shared/renderer/index.js +1 -1
  48. package/esm/src/server/utils/error-html.d.ts.map +1 -1
  49. package/esm/src/server/utils/error-html.js +26 -6
  50. package/package.json +1 -1
  51. package/src/cli/commands/knowledge/command-help.ts +3 -1
  52. package/src/cli/commands/knowledge/command.ts +104 -21
  53. package/src/cli/commands/knowledge/parser-source.ts +110 -5
  54. package/src/deno.js +1 -1
  55. package/src/src/html/html-shell-generator.ts +9 -0
  56. package/src/src/rendering/orchestrator/pipeline.ts +186 -172
  57. package/src/src/server/dev-server/error-overlay/error-formatter.ts +21 -0
  58. package/src/src/server/dev-server/error-overlay/html-template.ts +139 -8
  59. package/src/src/server/dev-server/error-overlay/index.ts +1 -0
  60. package/src/src/server/dev-server/error-overlay/overlay-renderer.ts +2 -1
  61. package/src/src/server/dev-server/request-handler.ts +6 -2
  62. package/src/src/server/handlers/request/ssr/ssr.handler.ts +11 -2
  63. package/src/src/server/runtime-handler/adapter-factory.ts +13 -5
  64. package/src/src/server/runtime-handler/index.ts +132 -39
  65. package/src/src/server/runtime-handler/local-project-discovery.ts +51 -17
  66. package/src/src/server/services/rendering/ssr.service.ts +43 -5
  67. package/src/src/server/shared/renderer/adapter.ts +107 -8
  68. package/src/src/server/shared/renderer/index.ts +7 -1
  69. package/src/src/server/utils/error-html.ts +29 -6
@@ -26,7 +26,11 @@ import { serverLogger } from "../../../../utils/index.js";
26
26
  import { endRequest, startRequest } from "../../../../utils/index.js";
27
27
  import { tryNotFoundFallback } from "./not-found-fallback.js";
28
28
  import { tryErrorPageFallback } from "./error-page-fallback.js";
29
- import { type SSRRenderResult, SSRService } from "../../../services/rendering/ssr.service.js";
29
+ import {
30
+ type SSRRenderResult,
31
+ SSRService,
32
+ type SSRServiceLike,
33
+ } from "../../../services/rendering/ssr.service.js";
30
34
  import { ErrorPages } from "../../../utils/error-html.js";
31
35
  import { buildSSRResponse } from "./ssr-response-builder.js";
32
36
 
@@ -62,7 +66,12 @@ export class SSRHandler extends BaseHandler {
62
66
  patterns: [{ pattern: /^(?!\/_).*/, method: ["GET", "HEAD"] }],
63
67
  };
64
68
 
65
- private ssrService = new SSRService();
69
+ private ssrService: SSRServiceLike;
70
+
71
+ constructor(ssrService?: SSRServiceLike) {
72
+ super();
73
+ this.ssrService = ssrService ?? new SSRService();
74
+ }
66
75
 
67
76
  handle(req: dntShim.Request, ctx: HandlerContext): Promise<HandlerResult> {
68
77
  const url = new URL(req.url);
@@ -15,7 +15,11 @@ import { isExtendedFSAdapter } from "../../platform/adapters/fs/wrapper.js";
15
15
  import { getConfig } from "../../config/loader.js";
16
16
  import type { VeryfrontConfig } from "../../config/index.js";
17
17
  import { timeAsync } from "./request-lifecycle.js";
18
- import { findLocalProjectPath, localAdapterCache } from "./local-project-discovery.js";
18
+ import {
19
+ defaultDiscoveryCache,
20
+ findLocalProjectPath,
21
+ type ProjectDiscoveryCache,
22
+ } from "./local-project-discovery.js";
19
23
  import type { ParsedDomain } from "../utils/domain-parser.js";
20
24
 
21
25
  const baseLogger = getBaseLogger("SERVER");
@@ -60,6 +64,8 @@ interface AdapterResolutionOptions {
60
64
  headerProjectPath: string | undefined;
61
65
  /** Whether running in proxy mode */
62
66
  isProxyMode: boolean;
67
+ /** Optional injectable cache (defaults to module-level singleton) */
68
+ cache?: ProjectDiscoveryCache;
63
69
  }
64
70
 
65
71
  /**
@@ -71,6 +77,8 @@ interface AdapterResolutionOptions {
71
77
  export async function resolveAdapter(
72
78
  opts: AdapterResolutionOptions,
73
79
  ): Promise<AdapterResolutionResult> {
80
+ const cache = opts.cache ?? defaultDiscoveryCache;
81
+
74
82
  let effectiveProjectDir = opts.projectDir;
75
83
  let effectiveAdapter = opts.adapter;
76
84
  let effectiveConfig = opts.config;
@@ -81,7 +89,7 @@ export async function resolveAdapter(
81
89
  const trustedHeaderProjectPath = opts.isProxyMode ? opts.headerProjectPath : undefined;
82
90
  const shouldCheckLocalPath = opts.projectSlug && (!opts.isProxyMode || trustedHeaderProjectPath);
83
91
  const localProjectPath = shouldCheckLocalPath
84
- ? await findLocalProjectPath(opts.projectSlug!, opts.adapter, trustedHeaderProjectPath)
92
+ ? await findLocalProjectPath(opts.projectSlug!, opts.adapter, trustedHeaderProjectPath, cache)
85
93
  : undefined;
86
94
 
87
95
  const isLocalProject = !!localProjectPath;
@@ -95,16 +103,16 @@ export async function resolveAdapter(
95
103
  });
96
104
 
97
105
  // Get or create local adapter
98
- if (!localAdapterCache.has(effectiveProjectDir)) {
106
+ if (!cache.adapters.has(effectiveProjectDir)) {
99
107
  const baseAdapter = await runtime.get();
100
- localAdapterCache.set(effectiveProjectDir, baseAdapter);
108
+ cache.adapters.set(effectiveProjectDir, baseAdapter);
101
109
  logger.debug("Created local adapter for project", {
102
110
  projectSlug: opts.projectSlug,
103
111
  projectDir: effectiveProjectDir,
104
112
  });
105
113
  }
106
114
 
107
- effectiveAdapter = localAdapterCache.get(effectiveProjectDir)!;
115
+ effectiveAdapter = cache.adapters.get(effectiveProjectDir)!;
108
116
 
109
117
  // Load project-specific config
110
118
  try {
@@ -21,6 +21,7 @@ import { getErrorMessage } from "../../errors/veryfront-error.js";
21
21
  import { UNKNOWN_ERROR } from "../../errors/error-registry.js";
22
22
  import { errorToRFC9457Response } from "../../errors/middleware/http-error-boundary.js";
23
23
  import { RouteRegistry } from "../../routing/registry/index.js";
24
+ import type { Handler } from "../../types/index.js";
24
25
  import { SecurityConfigLoader } from "../../security/http/config.js";
25
26
 
26
27
  // Re-export is at the bottom of the file
@@ -114,6 +115,136 @@ const baseLogger = getBaseLogger("SERVER");
114
115
 
115
116
  const logger = baseLogger.component("runtime-handler");
116
117
 
118
+ /** Handler names in registration order. */
119
+ export const HANDLER_NAMES = [
120
+ "AuthHandler",
121
+ "CsrfHandler",
122
+ "HMRHandler",
123
+ "CorsHandler",
124
+ "HealthHandler",
125
+ "MetricsHandler",
126
+ "MemoryDebugHandler",
127
+ "ClientLogHandler",
128
+ "DevEndpointsHandler",
129
+ "StylesCSSHandler",
130
+ "DebugContextHandler",
131
+ "OpenAPIHandler",
132
+ "OpenAPIDocsHandler",
133
+ "InternalAgentsListHandler",
134
+ "AgentStreamHandler",
135
+ "AgentRunResumeHandler",
136
+ "AgentRunCancelHandler",
137
+ "ChannelInvokeHandler",
138
+ "DevDashboardHandler",
139
+ "ProjectsHandler",
140
+ "StudioBridgeModulesHandler",
141
+ "CSSHandler",
142
+ "DevFileHandler",
143
+ "SnippetHandler",
144
+ "StaticHandler",
145
+ "LibModulesHandler",
146
+ "RSCHandler",
147
+ "ModuleHandler",
148
+ "ApiHandlerWrapper",
149
+ "MarkdownPreviewHandler",
150
+ "SSRHandler",
151
+ "NotFoundHandler",
152
+ ] as const;
153
+
154
+ /** Union of all registered handler names. */
155
+ export type HandlerName = (typeof HANDLER_NAMES)[number];
156
+
157
+ /**
158
+ * Dependencies for handler registry creation.
159
+ * All fields are optional — when omitted, the real handler implementation is used.
160
+ * This allows tests to inject mock handlers for specific slots.
161
+ */
162
+ export interface HandlerDependencies {
163
+ /** Override any handler by its typed name. */
164
+ overrides?: Partial<Record<HandlerName, Handler>>;
165
+ /** When true, log handler registration details. */
166
+ debug?: boolean;
167
+ }
168
+
169
+ /** Factory for each handler. Only called when no override is provided (lazy instantiation). */
170
+ const handlerFactories: Record<
171
+ HandlerName,
172
+ (projectDir: string, adapter: RuntimeAdapter) => Handler
173
+ > = {
174
+ AuthHandler: () => new AuthHandler(),
175
+ CsrfHandler: () => new CsrfHandler(),
176
+ HMRHandler: () => new HMRHandler(),
177
+ CorsHandler: () => new CorsHandler(),
178
+ HealthHandler: () => new HealthHandler(),
179
+ MetricsHandler: () => new MetricsHandler(),
180
+ MemoryDebugHandler: () => new MemoryDebugHandler(),
181
+ ClientLogHandler: () => new ClientLogHandler(),
182
+ DevEndpointsHandler: () => new DevEndpointsHandler(),
183
+ StylesCSSHandler: () => new StylesCSSHandler(),
184
+ DebugContextHandler: () => new DebugContextHandler(),
185
+ OpenAPIHandler: () => new OpenAPIHandler(),
186
+ OpenAPIDocsHandler: () => new OpenAPIDocsHandler(),
187
+ InternalAgentsListHandler: () => new InternalAgentsListHandler(),
188
+ AgentStreamHandler: () => new AgentStreamHandler(),
189
+ AgentRunResumeHandler: () => new AgentRunResumeHandler(),
190
+ AgentRunCancelHandler: () => new AgentRunCancelHandler(),
191
+ ChannelInvokeHandler: () => new ChannelInvokeHandler(),
192
+ DevDashboardHandler: () => new DevDashboardHandler(),
193
+ ProjectsHandler: () => new ProjectsHandler(),
194
+ StudioBridgeModulesHandler: () => new StudioBridgeModulesHandler(),
195
+ CSSHandler: () => new CSSHandler(),
196
+ DevFileHandler: () => new DevFileHandler(),
197
+ SnippetHandler: () => new SnippetHandler(),
198
+ StaticHandler: () => new StaticHandler(),
199
+ LibModulesHandler: () => new LibModulesHandler(),
200
+ RSCHandler: () => new RSCHandler(),
201
+ ModuleHandler: () => new ModuleHandler(),
202
+ ApiHandlerWrapper: (projectDir, adapter) => new ApiHandlerWrapper(projectDir, adapter),
203
+ MarkdownPreviewHandler: () => new MarkdownPreviewHandler(),
204
+ SSRHandler: () => new SSRHandler(),
205
+ NotFoundHandler: () => new NotFoundHandler(),
206
+ };
207
+
208
+ /**
209
+ * Creates a RouteRegistry populated with the standard handler chain.
210
+ *
211
+ * Handlers are instantiated lazily — overridden slots skip construction
212
+ * of the default handler entirely.
213
+ *
214
+ * @param projectDir - Root project directory
215
+ * @param adapter - Runtime adapter for environment access
216
+ * @param deps - Optional dependency overrides for testing
217
+ * @returns Object containing the registry and the api handler (for initialization)
218
+ */
219
+ export function createHandlerRegistry(
220
+ projectDir: string,
221
+ adapter: RuntimeAdapter,
222
+ deps: HandlerDependencies = {},
223
+ ): { registry: RouteRegistry; apiHandler: ApiHandlerWrapper } {
224
+ const registry = new RouteRegistry({
225
+ debug: deps.debug,
226
+ enableMetrics: true,
227
+ });
228
+
229
+ const overrides = deps.overrides ?? {};
230
+
231
+ // Create the ApiHandlerWrapper first — it's special because callers need
232
+ // the returned instance for initialization regardless of overrides.
233
+ const apiHandler = overrides.ApiHandlerWrapper
234
+ ? (overrides.ApiHandlerWrapper as ApiHandlerWrapper)
235
+ : new ApiHandlerWrapper(projectDir, adapter);
236
+
237
+ const handlers = HANDLER_NAMES.map((name) => {
238
+ if (name === "ApiHandlerWrapper") return apiHandler;
239
+ if (overrides[name]) return overrides[name]!;
240
+ return handlerFactories[name](projectDir, adapter);
241
+ });
242
+
243
+ registry.registerAll(handlers);
244
+
245
+ return { registry, apiHandler };
246
+ }
247
+
117
248
  export interface RuntimeHandlerOptions {
118
249
  projectDir: string;
119
250
  /** When true, expose additional debug logging. */
@@ -188,48 +319,10 @@ export function createVeryfrontHandler(
188
319
  }
189
320
  })();
190
321
 
191
- const registry = new RouteRegistry({
322
+ const { registry, apiHandler } = createHandlerRegistry(projectDir, adapter, {
192
323
  debug: opts.debug,
193
- enableMetrics: true,
194
324
  });
195
325
 
196
- const apiHandler = new ApiHandlerWrapper(projectDir, adapter);
197
-
198
- registry.registerAll([
199
- new AuthHandler(),
200
- new CsrfHandler(),
201
- new HMRHandler(),
202
- new CorsHandler(),
203
- new HealthHandler(),
204
- new MetricsHandler(),
205
- new MemoryDebugHandler(),
206
- new ClientLogHandler(),
207
- new DevEndpointsHandler(),
208
- new StylesCSSHandler(),
209
- new DebugContextHandler(),
210
- new OpenAPIHandler(),
211
- new OpenAPIDocsHandler(),
212
- new InternalAgentsListHandler(),
213
- new AgentStreamHandler(),
214
- new AgentRunResumeHandler(),
215
- new AgentRunCancelHandler(),
216
- new ChannelInvokeHandler(),
217
- new DevDashboardHandler(),
218
- new ProjectsHandler(),
219
- new StudioBridgeModulesHandler(),
220
- new CSSHandler(),
221
- new DevFileHandler(),
222
- new SnippetHandler(),
223
- new StaticHandler(),
224
- new LibModulesHandler(),
225
- new RSCHandler(),
226
- new ModuleHandler(),
227
- apiHandler,
228
- new MarkdownPreviewHandler(),
229
- new SSRHandler(),
230
- new NotFoundHandler(),
231
- ]);
232
-
233
326
  const isProxyMode = opts.config?.fs?.veryfront?.proxyMode === true;
234
327
 
235
328
  const readyPromise = isProxyMode ? Promise.resolve() : apiHandler.initialize().catch((error) => {
@@ -17,24 +17,56 @@ const baseLogger = getBaseLogger("SERVER");
17
17
 
18
18
  const logger = baseLogger.component("runtime-handler");
19
19
 
20
- /** Cache of local adapters by project directory */
21
- export const localAdapterCache = new LRUCache<string, RuntimeAdapter>({
22
- maxEntries: 50,
23
- });
20
+ /**
21
+ * Injectable cache container for project discovery state.
22
+ *
23
+ * Wraps both the project-path cache (slug → absolute path) and the
24
+ * adapter cache (project dir → RuntimeAdapter) so that callers — especially
25
+ * tests — can supply an isolated instance instead of sharing global state.
26
+ */
27
+ export class ProjectDiscoveryCache {
28
+ /** Cache of discovered local project paths by slug */
29
+ readonly projects: LRUCache<string, string>;
30
+ /** Cache of local adapters by project directory */
31
+ readonly adapters: LRUCache<string, RuntimeAdapter>;
32
+
33
+ constructor(opts?: { maxProjects?: number; maxAdapters?: number }) {
34
+ this.projects = new LRUCache<string, string>({
35
+ maxEntries: opts?.maxProjects ?? 100,
36
+ });
37
+ this.adapters = new LRUCache<string, RuntimeAdapter>({
38
+ maxEntries: opts?.maxAdapters ?? 50,
39
+ });
40
+ }
24
41
 
25
- // Register cache for monitoring
26
- registerLRUCache("local-adapter-cache", localAdapterCache);
42
+ /** Clear both caches */
43
+ clear(): void {
44
+ this.projects.clear();
45
+ this.adapters.clear();
46
+ }
47
+ }
27
48
 
28
- /** Standard directories to search for local projects */
29
- export const standardProjectDirs = ["data/projects", "projects"];
49
+ /** Default module-level cache instance (backward-compatible singleton) */
50
+ export const defaultDiscoveryCache = new ProjectDiscoveryCache();
30
51
 
31
- /** Cache of discovered local project paths by slug */
32
- export const localProjectCache = new LRUCache<string, string>({
33
- maxEntries: 100,
34
- });
52
+ // Register the default caches for monitoring
53
+ registerLRUCache("local-project-cache", defaultDiscoveryCache.projects);
54
+ registerLRUCache("local-adapter-cache", defaultDiscoveryCache.adapters);
35
55
 
36
- // Register cache for monitoring
37
- registerLRUCache("local-project-cache", localProjectCache);
56
+ /**
57
+ * @deprecated Use `defaultDiscoveryCache.adapters` instead.
58
+ * Kept for backward compatibility with existing consumers.
59
+ */
60
+ export const localAdapterCache = defaultDiscoveryCache.adapters;
61
+
62
+ /**
63
+ * @deprecated Use `defaultDiscoveryCache.projects` instead.
64
+ * Kept for backward compatibility with existing consumers.
65
+ */
66
+ export const localProjectCache = defaultDiscoveryCache.projects;
67
+
68
+ /** Standard directories to search for local projects */
69
+ export const standardProjectDirs = ["data/projects", "projects"];
38
70
 
39
71
  function isNotFoundError(error: unknown): boolean {
40
72
  if (!(error instanceof Error)) return false;
@@ -78,12 +110,14 @@ async function isValidLocalProjectPath(path: string, adapter: RuntimeAdapter): P
78
110
  * @param slug - The project slug to find
79
111
  * @param adapter - The runtime adapter to use for filesystem operations
80
112
  * @param headerPath - Optional path from x-project-path header (takes precedence)
113
+ * @param cache - Optional cache instance (defaults to module-level singleton)
81
114
  * @returns The absolute path to the project, or undefined if not found
82
115
  */
83
116
  export async function findLocalProjectPath(
84
117
  slug: string,
85
118
  adapter: RuntimeAdapter,
86
119
  headerPath?: string,
120
+ cache: ProjectDiscoveryCache = defaultDiscoveryCache,
87
121
  ): Promise<string | undefined> {
88
122
  if (headerPath) {
89
123
  try {
@@ -92,7 +126,7 @@ export async function findLocalProjectPath(
92
126
  const absolutePath = normalizedPath.startsWith("/")
93
127
  ? normalizedPath
94
128
  : `${cwd()}/${normalizedPath}`;
95
- localProjectCache.set(slug, absolutePath);
129
+ cache.projects.set(slug, absolutePath);
96
130
  return absolutePath;
97
131
  }
98
132
  logger.warn("Ignoring invalid x-project-path override", {
@@ -108,7 +142,7 @@ export async function findLocalProjectPath(
108
142
  }
109
143
  }
110
144
 
111
- const cached = localProjectCache.get(slug);
145
+ const cached = cache.projects.get(slug);
112
146
  if (cached) return cached;
113
147
 
114
148
  for (const dir of standardProjectDirs) {
@@ -118,7 +152,7 @@ export async function findLocalProjectPath(
118
152
  if (!await isValidLocalProjectPath(projectPath, adapter)) continue;
119
153
 
120
154
  const absolutePath = projectPath.startsWith("/") ? projectPath : `${cwd()}/${projectPath}`;
121
- localProjectCache.set(slug, absolutePath);
155
+ cache.projects.set(slug, absolutePath);
122
156
  logger.debug("Discovered local project", { slug, path: absolutePath });
123
157
  return absolutePath;
124
158
  } catch (error) {
@@ -15,7 +15,7 @@ import {
15
15
  startRenderSession,
16
16
  } from "../../../transforms/mdx/esm-module-loader/module-fetcher/index.js";
17
17
  import { getErrorCollector } from "../../../observability/error-collector.js";
18
- import { ErrorOverlay } from "../../dev-server/error-overlay/index.js";
18
+ import { ErrorOverlay, parseErrorLocation } from "../../dev-server/error-overlay/index.js";
19
19
  import { ErrorPages } from "../../utils/error-html.js";
20
20
  import {
21
21
  HTTP_INTERNAL_SERVER_ERROR,
@@ -27,6 +27,32 @@ import type { CacheRepository } from "../../../repositories/types.js";
27
27
 
28
28
  const logger = serverLogger.component("ssr-service");
29
29
 
30
+ /**
31
+ * Provides a renderer for a given handler context.
32
+ * Extracted to allow dependency injection in tests.
33
+ */
34
+ export interface RendererProvider {
35
+ getRenderer(ctx: HandlerContext): Promise<RendererAdapter>;
36
+ }
37
+
38
+ /**
39
+ * Minimal interface for SSRService consumers (e.g., SSRHandler).
40
+ * Allows dependency injection and mocking in tests.
41
+ */
42
+ export interface SSRServiceLike {
43
+ checkMemoryPressure(): MemoryStatus;
44
+ renderPage(ctx: HandlerContext, options: SSRRenderOptions): Promise<SSRRenderResult>;
45
+ createMemoryPressureResult(slug: string): SSRRenderResult;
46
+ }
47
+
48
+ /**
49
+ * Default RendererProvider that delegates to the real getRendererForProject.
50
+ */
51
+ const defaultRendererProvider: RendererProvider = {
52
+ getRenderer: (ctx: HandlerContext) =>
53
+ timeAsync("renderer-init", () => getRendererForProject(ctx)),
54
+ };
55
+
30
56
  export interface SSRRenderResult {
31
57
  status: number;
32
58
  html?: string;
@@ -59,11 +85,16 @@ export interface MemoryStatus {
59
85
  heapUsedPercent: number;
60
86
  }
61
87
 
62
- export class SSRService {
88
+ export class SSRService implements SSRServiceLike {
63
89
  private readonly cacheRepo?: CacheRepository<string>;
90
+ private readonly rendererProvider: RendererProvider;
64
91
 
65
- constructor(options?: { cacheRepo?: CacheRepository<string> }) {
92
+ constructor(options?: {
93
+ cacheRepo?: CacheRepository<string>;
94
+ rendererProvider?: RendererProvider;
95
+ }) {
66
96
  this.cacheRepo = options?.cacheRepo;
97
+ this.rendererProvider = options?.rendererProvider ?? defaultRendererProvider;
67
98
  }
68
99
 
69
100
  checkMemoryPressure(): MemoryStatus {
@@ -78,7 +109,7 @@ export class SSRService {
78
109
  }
79
110
 
80
111
  async getRenderer(ctx: HandlerContext): Promise<RendererAdapter> {
81
- return timeAsync("renderer-init", () => getRendererForProject(ctx));
112
+ return this.rendererProvider.getRenderer(ctx);
82
113
  }
83
114
 
84
115
  async renderPage(ctx: HandlerContext, options: SSRRenderOptions): Promise<SSRRenderResult> {
@@ -240,9 +271,16 @@ export class SSRService {
240
271
  slug,
241
272
  });
242
273
 
274
+ const sourceFile = (errorObj as Error & { sourceFile?: string }).sourceFile;
275
+ const location = sourceFile ? parseErrorLocation(errorObj, sourceFile) : {};
243
276
  return {
244
277
  status: HTTP_INTERNAL_SERVER_ERROR,
245
- html: ErrorOverlay.createHTML({ error: errorObj, type: "runtime" }),
278
+ html: ErrorOverlay.createHTML({
279
+ error: errorObj,
280
+ type: "runtime",
281
+ ...(sourceFile ? { file: sourceFile } : {}),
282
+ ...location,
283
+ }, ctx.projectSlug),
246
284
  isStreaming: false,
247
285
  cacheStrategy: "no-cache",
248
286
  error: errorObj,
@@ -64,11 +64,95 @@ export interface RendererAdapter {
64
64
  destroy(): Promise<void>;
65
65
  }
66
66
 
67
- let rendererInitPromise: Promise<Renderer> | null = null;
67
+ /**
68
+ * Abstraction over renderer initialization, allowing tests to inject
69
+ * a mock renderer without pulling in the full rendering subsystem.
70
+ */
71
+ export interface RendererInitializer {
72
+ initialize(options: RendererOptions): Promise<Renderer>;
73
+ isInitialized(): boolean;
74
+ get(): Renderer;
75
+ destroy(): Promise<void>;
76
+ }
77
+
78
+ /**
79
+ * Default initializer that delegates to the real shared renderer
80
+ * singleton from `#veryfront/rendering/renderer.ts`.
81
+ */
82
+ const defaultInitializer: RendererInitializer = {
83
+ initialize: initializeRenderer,
84
+ isInitialized: isRendererInitialized,
85
+ get: getRenderer,
86
+ destroy: destroySharedRenderer,
87
+ };
88
+
89
+ let activeInitializer: RendererInitializer = defaultInitializer;
90
+ let rendererInitState: { initializer: RendererInitializer; promise: Promise<Renderer> } | null =
91
+ null;
92
+
93
+ function scheduleInitializerDestroy(
94
+ initializer: RendererInitializer,
95
+ pendingPromise?: Promise<unknown>,
96
+ ): void {
97
+ const destroy = async () => {
98
+ try {
99
+ await initializer.destroy();
100
+ } catch (error) {
101
+ logger.warn("Failed to destroy renderer initializer", {
102
+ error: error instanceof Error ? error.message : String(error),
103
+ });
104
+ }
105
+ };
106
+
107
+ if (pendingPromise) {
108
+ void pendingPromise
109
+ .catch(() => undefined)
110
+ .then(destroy);
111
+ return;
112
+ }
113
+
114
+ if (!initializer.isInitialized()) return;
115
+ void destroy();
116
+ }
117
+
118
+ /**
119
+ * Replace the renderer initializer used by the adapter layer.
120
+ * Pass `undefined` to restore the default (real) initializer.
121
+ *
122
+ * Returns a disposer that restores the previous initializer — use in
123
+ * `afterEach` or with `using` to prevent test pollution:
124
+ *
125
+ * ```ts
126
+ * afterEach(() => setRendererInitializer(undefined));
127
+ * ```
128
+ *
129
+ * @internal Test-only — not part of the public API.
130
+ */
131
+ export function setRendererInitializer(
132
+ initializer?: RendererInitializer,
133
+ ): void {
134
+ const nextInitializer = initializer ?? defaultInitializer;
135
+ const previous = activeInitializer;
136
+ const previousPendingPromise = rendererInitState?.initializer === previous
137
+ ? rendererInitState.promise
138
+ : undefined;
139
+
140
+ activeInitializer = nextInitializer;
141
+
142
+ if (rendererInitState?.initializer !== activeInitializer) {
143
+ rendererInitState = null;
144
+ }
145
+
146
+ if (previous !== activeInitializer) {
147
+ scheduleInitializerDestroy(previous, previousPendingPromise);
148
+ }
149
+ }
68
150
 
69
151
  async function getOrInitRenderer(): Promise<Renderer> {
70
- if (isRendererInitialized()) return getRenderer();
71
- if (rendererInitPromise) return rendererInitPromise;
152
+ if (activeInitializer.isInitialized()) return activeInitializer.get();
153
+ if (rendererInitState?.initializer === activeInitializer) {
154
+ return rendererInitState.promise;
155
+ }
72
156
 
73
157
  const isProxyMode = getEnvBoolean("PROXY_MODE", false, {
74
158
  trueValues: ["1"],
@@ -99,12 +183,19 @@ async function getOrInitRenderer(): Promise<Renderer> {
99
183
  cacheType: useApiCache ? "api-distributed" : "memory",
100
184
  });
101
185
 
102
- rendererInitPromise = initializeRenderer(options);
186
+ const initializer = activeInitializer;
187
+ const initPromise = initializer.initialize(options);
188
+ rendererInitState = {
189
+ initializer,
190
+ promise: initPromise,
191
+ };
103
192
 
104
193
  try {
105
- return await rendererInitPromise;
194
+ return await initPromise;
106
195
  } finally {
107
- rendererInitPromise = null;
196
+ if (rendererInitState?.promise === initPromise) {
197
+ rendererInitState = null;
198
+ }
108
199
  }
109
200
  }
110
201
 
@@ -308,6 +399,14 @@ export async function getRendererForProject(ctx: HandlerContext): Promise<Render
308
399
  }
309
400
 
310
401
  export async function destroyRendererAdapter(): Promise<void> {
311
- await destroySharedRenderer();
312
- rendererInitPromise = null;
402
+ const pendingPromise = rendererInitState?.initializer === activeInitializer
403
+ ? rendererInitState.promise
404
+ : undefined;
405
+ rendererInitState = null;
406
+
407
+ if (pendingPromise) {
408
+ await pendingPromise.catch(() => undefined);
409
+ }
410
+
411
+ await activeInitializer.destroy();
313
412
  }
@@ -4,5 +4,11 @@
4
4
  * @module server/shared/renderer
5
5
  */
6
6
 
7
- export { destroyRendererAdapter, getRendererForProject, type RendererAdapter } from "./adapter.js";
7
+ export {
8
+ destroyRendererAdapter,
9
+ getRendererForProject,
10
+ type RendererAdapter,
11
+ type RendererInitializer,
12
+ setRendererInitializer,
13
+ } from "./adapter.js";
8
14
  export { shouldRejectDueToMemory } from "./memory/pressure.js";