veryfront 0.1.26 → 0.1.28

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 (124) hide show
  1. package/README.md +3 -11
  2. package/esm/cli/app/shell.d.ts.map +1 -1
  3. package/esm/cli/app/shell.js +9 -5
  4. package/esm/cli/commands/demo/demo.js +1 -1
  5. package/esm/cli/commands/init/catalog.d.ts.map +1 -1
  6. package/esm/cli/commands/init/catalog.js +13 -5
  7. package/esm/cli/commands/init/command-help.js +4 -4
  8. package/esm/cli/commands/init/types.d.ts +1 -1
  9. package/esm/cli/commands/init/types.d.ts.map +1 -1
  10. package/esm/cli/commands/serve/command.d.ts.map +1 -1
  11. package/esm/cli/commands/serve/command.js +0 -4
  12. package/esm/cli/commands/start/command.d.ts.map +1 -1
  13. package/esm/cli/commands/start/command.js +16 -9
  14. package/esm/cli/help/tips.js +6 -6
  15. package/esm/cli/mcp/remote-file-tools.js +1 -1
  16. package/esm/cli/mcp/tools/catalog-tools.d.ts +3 -3
  17. package/esm/cli/mcp/tools/catalog-tools.d.ts.map +1 -1
  18. package/esm/cli/mcp/tools/catalog-tools.js +21 -13
  19. package/esm/cli/mcp/tools/project-tools.js +1 -1
  20. package/esm/cli/templates/index.js +11 -11
  21. package/esm/cli/templates/manifest.d.ts +22 -15
  22. package/esm/cli/templates/manifest.js +24 -17
  23. package/esm/cli/templates/types.d.ts +1 -1
  24. package/esm/cli/templates/types.d.ts.map +1 -1
  25. package/esm/cli/utils/index.d.ts.map +1 -1
  26. package/esm/cli/utils/index.js +13 -1
  27. package/esm/deno.js +1 -1
  28. package/esm/src/html/html-shell-generator.d.ts.map +1 -1
  29. package/esm/src/html/html-shell-generator.js +2 -0
  30. package/esm/src/html/styles-builder/project-css-cache.d.ts +8 -1
  31. package/esm/src/html/styles-builder/project-css-cache.d.ts.map +1 -1
  32. package/esm/src/html/styles-builder/project-css-cache.js +13 -2
  33. package/esm/src/html/styles-builder/tailwind-compiler.d.ts +2 -0
  34. package/esm/src/html/styles-builder/tailwind-compiler.d.ts.map +1 -1
  35. package/esm/src/html/styles-builder/tailwind-compiler.js +52 -19
  36. package/esm/src/modules/react-loader/css-import-collector.d.ts +29 -0
  37. package/esm/src/modules/react-loader/css-import-collector.d.ts.map +1 -0
  38. package/esm/src/modules/react-loader/css-import-collector.js +41 -0
  39. package/esm/src/modules/react-loader/ssr-module-loader/loader.d.ts.map +1 -1
  40. package/esm/src/modules/react-loader/ssr-module-loader/loader.js +6 -0
  41. package/esm/src/modules/react-loader/ssr-module-loader/ssr-dependency-validator.d.ts.map +1 -1
  42. package/esm/src/modules/react-loader/ssr-module-loader/ssr-dependency-validator.js +5 -0
  43. package/esm/src/platform/adapters/fs/factory.d.ts.map +1 -1
  44. package/esm/src/platform/adapters/fs/factory.js +5 -1
  45. package/esm/src/platform/adapters/fs/veryfront/websocket-manager.d.ts +1 -0
  46. package/esm/src/platform/adapters/fs/veryfront/websocket-manager.d.ts.map +1 -1
  47. package/esm/src/platform/adapters/fs/veryfront/websocket-manager.js +19 -5
  48. package/esm/src/platform/compat/process.d.ts.map +1 -1
  49. package/esm/src/platform/compat/process.js +20 -3
  50. package/esm/src/proxy/main.js +31 -12
  51. package/esm/src/proxy/token-manager.d.ts +2 -0
  52. package/esm/src/proxy/token-manager.d.ts.map +1 -1
  53. package/esm/src/proxy/token-manager.js +47 -8
  54. package/esm/src/rendering/orchestrator/css-candidate-manifest.d.ts +23 -0
  55. package/esm/src/rendering/orchestrator/css-candidate-manifest.d.ts.map +1 -0
  56. package/esm/src/rendering/orchestrator/css-candidate-manifest.js +132 -0
  57. package/esm/src/rendering/orchestrator/html.d.ts +11 -1
  58. package/esm/src/rendering/orchestrator/html.d.ts.map +1 -1
  59. package/esm/src/rendering/orchestrator/html.js +103 -18
  60. package/esm/src/rendering/orchestrator/pipeline.d.ts.map +1 -1
  61. package/esm/src/rendering/orchestrator/pipeline.js +14 -2
  62. package/esm/src/server/bootstrap.d.ts +2 -0
  63. package/esm/src/server/bootstrap.d.ts.map +1 -1
  64. package/esm/src/server/bootstrap.js +10 -0
  65. package/esm/src/server/handlers/preview/markdown-html-generator.d.ts.map +1 -1
  66. package/esm/src/server/handlers/preview/markdown-html-generator.js +11 -5
  67. package/esm/src/server/production-server.js +10 -2
  68. package/esm/src/studio/bridge-template.d.ts +2 -0
  69. package/esm/src/studio/bridge-template.d.ts.map +1 -1
  70. package/esm/src/studio/bridge-template.js +3390 -52
  71. package/esm/src/transforms/css-modules/naming.d.ts +33 -0
  72. package/esm/src/transforms/css-modules/naming.d.ts.map +1 -0
  73. package/esm/src/transforms/css-modules/naming.js +128 -0
  74. package/esm/src/transforms/esm/import-parser.d.ts +1 -0
  75. package/esm/src/transforms/esm/import-parser.d.ts.map +1 -1
  76. package/esm/src/transforms/esm/import-parser.js +16 -5
  77. package/esm/src/transforms/pipeline/index.d.ts.map +1 -1
  78. package/esm/src/transforms/pipeline/index.js +3 -1
  79. package/esm/src/transforms/pipeline/stages/index.d.ts +1 -0
  80. package/esm/src/transforms/pipeline/stages/index.d.ts.map +1 -1
  81. package/esm/src/transforms/pipeline/stages/index.js +1 -0
  82. package/esm/src/transforms/pipeline/stages/ssr-css-strip.d.ts +18 -0
  83. package/esm/src/transforms/pipeline/stages/ssr-css-strip.d.ts.map +1 -0
  84. package/esm/src/transforms/pipeline/stages/ssr-css-strip.js +168 -0
  85. package/package.json +1 -1
  86. package/src/cli/app/shell.ts +9 -5
  87. package/src/cli/commands/demo/demo.ts +1 -1
  88. package/src/cli/commands/init/catalog.ts +13 -5
  89. package/src/cli/commands/init/command-help.ts +4 -4
  90. package/src/cli/commands/init/types.ts +5 -5
  91. package/src/cli/commands/serve/command.ts +0 -5
  92. package/src/cli/commands/start/command.ts +15 -10
  93. package/src/cli/help/tips.ts +6 -6
  94. package/src/cli/mcp/remote-file-tools.ts +1 -1
  95. package/src/cli/mcp/tools/catalog-tools.ts +21 -13
  96. package/src/cli/mcp/tools/project-tools.ts +1 -1
  97. package/src/cli/templates/index.ts +11 -11
  98. package/src/cli/templates/manifest.js +24 -17
  99. package/src/cli/templates/types.ts +5 -5
  100. package/src/cli/utils/index.ts +12 -1
  101. package/src/deno.js +1 -1
  102. package/src/src/html/html-shell-generator.ts +2 -0
  103. package/src/src/html/styles-builder/project-css-cache.ts +24 -1
  104. package/src/src/html/styles-builder/tailwind-compiler.ts +67 -26
  105. package/src/src/modules/react-loader/css-import-collector.ts +50 -0
  106. package/src/src/modules/react-loader/ssr-module-loader/loader.ts +7 -0
  107. package/src/src/modules/react-loader/ssr-module-loader/ssr-dependency-validator.ts +6 -0
  108. package/src/src/platform/adapters/fs/factory.ts +5 -1
  109. package/src/src/platform/adapters/fs/veryfront/websocket-manager.ts +21 -5
  110. package/src/src/platform/compat/process.ts +28 -4
  111. package/src/src/proxy/main.ts +32 -12
  112. package/src/src/proxy/token-manager.ts +54 -8
  113. package/src/src/rendering/orchestrator/css-candidate-manifest.ts +176 -0
  114. package/src/src/rendering/orchestrator/html.ts +128 -16
  115. package/src/src/rendering/orchestrator/pipeline.ts +183 -165
  116. package/src/src/server/bootstrap.ts +16 -0
  117. package/src/src/server/handlers/preview/markdown-html-generator.ts +12 -5
  118. package/src/src/server/production-server.ts +12 -2
  119. package/src/src/studio/bridge-template.ts +3392 -52
  120. package/src/src/transforms/css-modules/naming.ts +152 -0
  121. package/src/src/transforms/esm/import-parser.ts +15 -5
  122. package/src/src/transforms/pipeline/index.ts +3 -0
  123. package/src/src/transforms/pipeline/stages/index.ts +1 -0
  124. package/src/src/transforms/pipeline/stages/ssr-css-strip.ts +201 -0
@@ -18,6 +18,7 @@ import {
18
18
  resolveStylesheet,
19
19
  } from "./tailwind-compiler-utils.js";
20
20
  import { cacheCSSAsync, DEFAULT_STYLESHEET } from "./css-hash-cache.js";
21
+ import { TAILWIND_VERSION } from "../../utils/constants/cdn.js";
21
22
 
22
23
  const projectCssCacheLog = logger.component("project-css-cache");
23
24
  const tailwindLog = logger.component("tailwind");
@@ -40,9 +41,17 @@ export interface ProjectCSSRequestContext {
40
41
  projectSlug: string;
41
42
  stylesheet: string;
42
43
  candidatesHash: string;
44
+ profileHash: string;
45
+ environment: string;
43
46
  cacheKey: string;
44
47
  }
45
48
 
49
+ export interface ProjectCSSProfile {
50
+ minify?: boolean;
51
+ environment?: string;
52
+ buildMode?: "development" | "production";
53
+ }
54
+
46
55
  // ============================================================================
47
56
  // Constants
48
57
  // ============================================================================
@@ -116,15 +125,29 @@ export function createProjectCSSRequestContext(
116
125
  projectSlug: string,
117
126
  stylesheet: string | undefined,
118
127
  candidates: Set<string>,
128
+ profile?: ProjectCSSProfile,
119
129
  ): ProjectCSSRequestContext {
120
130
  const resolvedStylesheet = resolveStylesheet(stylesheet, DEFAULT_STYLESHEET);
121
131
  const stylesheetHash = hashString(resolvedStylesheet);
122
132
  const candidatesHash = hashCandidates(candidates);
133
+ const environment = profile?.environment ?? "preview";
134
+ const profileHash = hashString(
135
+ JSON.stringify({
136
+ cacheSchema: "v2",
137
+ tailwindVersion: TAILWIND_VERSION,
138
+ minify: profile?.minify ?? false,
139
+ buildMode: profile?.buildMode ?? "production",
140
+ environment,
141
+ }),
142
+ );
143
+
123
144
  return {
124
145
  projectSlug,
125
146
  stylesheet: resolvedStylesheet,
126
147
  candidatesHash,
127
- cacheKey: `${projectSlug}:${stylesheetHash}`,
148
+ profileHash,
149
+ environment,
150
+ cacheKey: `${projectSlug}:${environment}:${stylesheetHash}:${candidatesHash}:${profileHash}`,
128
151
  };
129
152
  }
130
153
 
@@ -43,6 +43,11 @@ export {
43
43
  } from "./project-css-cache.js";
44
44
 
45
45
  const logger = serverLogger.component("tailwind");
46
+ const inFlightProjectCSS = new Map<
47
+ string,
48
+ Promise<{ css: string; hash: string; fromCache: boolean }>
49
+ >();
50
+ const inFlightRegeneration = new Map<string, Promise<string | undefined>>();
46
51
 
47
52
  export interface TailwindResult {
48
53
  css: string;
@@ -51,6 +56,8 @@ export interface TailwindResult {
51
56
 
52
57
  export interface GenerateOptions {
53
58
  minify?: boolean;
59
+ environment?: string;
60
+ buildMode?: "development" | "production";
54
61
  }
55
62
 
56
63
  export interface CSSErrorInfo {
@@ -69,7 +76,11 @@ export async function getProjectCSS(
69
76
  candidates: Set<string>,
70
77
  options?: GenerateOptions,
71
78
  ): Promise<{ css: string; hash: string; fromCache: boolean }> {
72
- const context = createProjectCSSRequestContext(projectSlug, stylesheet, candidates);
79
+ const context = createProjectCSSRequestContext(projectSlug, stylesheet, candidates, {
80
+ minify: options?.minify,
81
+ environment: options?.environment,
82
+ buildMode: options?.buildMode,
83
+ });
73
84
 
74
85
  const localHit = await tryGetProjectCSSFromLocalFallback(context, candidates);
75
86
  if (localHit) return localHit;
@@ -81,36 +92,55 @@ export async function getProjectCSS(
81
92
  const distributedHit = await tryGetProjectCSSFromDistributedCache(context, candidates);
82
93
  if (distributedHit) return distributedHit;
83
94
 
84
- // Generate fresh CSS
85
- const result = await generateTailwindCSS(context.stylesheet, candidates, options);
86
-
87
- if (result.error) {
88
- const formatted = formatCSSError(result.error);
89
- logger.error("Project CSS generation failed", {
95
+ const inFlight = inFlightProjectCSS.get(context.cacheKey);
96
+ if (inFlight) {
97
+ logger.debug("Project CSS compile single-flight hit", {
90
98
  projectSlug: context.projectSlug,
91
- error: formatted.message,
92
- suggestion: formatted.suggestion,
99
+ cacheKeySuffix: context.cacheKey.slice(-24),
93
100
  });
94
- throw new Error(
95
- `[tailwind] ${formatted.title}: ${formatted.message} Suggestion: ${formatted.suggestion}`,
96
- );
101
+ return inFlight;
97
102
  }
98
103
 
99
- const hash = hashCSS(result.css);
100
- await storeProjectCSS(
101
- context,
102
- { css: result.css, hash, candidatesHash: context.candidatesHash },
103
- candidates,
104
- );
104
+ const generationPromise = (async () => {
105
+ // Generate fresh CSS
106
+ const result = await generateTailwindCSS(context.stylesheet, candidates, options);
105
107
 
106
- logger.debug("Project CSS generated", {
107
- projectSlug: context.projectSlug,
108
- hash,
109
- cssLength: result.css.length,
110
- candidateCount: candidates.size,
111
- });
108
+ if (result.error) {
109
+ const formatted = formatCSSError(result.error);
110
+ logger.error("Project CSS generation failed", {
111
+ projectSlug: context.projectSlug,
112
+ error: formatted.message,
113
+ suggestion: formatted.suggestion,
114
+ });
115
+ throw new Error(
116
+ `[tailwind] ${formatted.title}: ${formatted.message} Suggestion: ${formatted.suggestion}`,
117
+ );
118
+ }
119
+
120
+ const hash = hashCSS(result.css);
121
+ await storeProjectCSS(
122
+ context,
123
+ { css: result.css, hash, candidatesHash: context.candidatesHash },
124
+ candidates,
125
+ );
126
+
127
+ logger.debug("Project CSS generated", {
128
+ projectSlug: context.projectSlug,
129
+ hash,
130
+ cssLength: result.css.length,
131
+ candidateCount: candidates.size,
132
+ });
133
+
134
+ return { css: result.css, hash, fromCache: false };
135
+ })();
136
+
137
+ inFlightProjectCSS.set(context.cacheKey, generationPromise);
112
138
 
113
- return { css: result.css, hash, fromCache: false };
139
+ try {
140
+ return await generationPromise;
141
+ } finally {
142
+ inFlightProjectCSS.delete(context.cacheKey);
143
+ }
114
144
  }
115
145
 
116
146
  // ============================================================================
@@ -128,7 +158,10 @@ export async function getProjectCSS(
128
158
  * @returns The regenerated CSS if inputs are cached and hash matches, undefined otherwise
129
159
  */
130
160
  export async function regenerateCSSByHash(expectedHash: string): Promise<string | undefined> {
131
- return await withSpan(
161
+ const inFlight = inFlightRegeneration.get(expectedHash);
162
+ if (inFlight) return await inFlight;
163
+
164
+ const regenerationPromise = withSpan(
132
165
  SpanNames.HTML_REGENERATE_CSS_BY_HASH,
133
166
  async () => {
134
167
  const inputs = await resolveRegenerationInputs(expectedHash);
@@ -175,6 +208,14 @@ export async function regenerateCSSByHash(expectedHash: string): Promise<string
175
208
  },
176
209
  { "css.hash": expectedHash },
177
210
  );
211
+
212
+ inFlightRegeneration.set(expectedHash, regenerationPromise);
213
+
214
+ try {
215
+ return await regenerationPromise;
216
+ } finally {
217
+ inFlightRegeneration.delete(expectedHash);
218
+ }
178
219
  }
179
220
 
180
221
  // ============================================================================
@@ -0,0 +1,50 @@
1
+ /**
2
+ * CSS Import Collector - Request-scoped CSS import tracking for SSR
3
+ *
4
+ * Collects CSS import paths discovered during module loading using
5
+ * AsyncLocalStorage for proper isolation between concurrent requests.
6
+ *
7
+ * Usage:
8
+ * const { result, cssImports } = await runWithCSSCollector(() => loadModules(...));
9
+ * // cssImports contains absolute paths to CSS files discovered during loading
10
+ */
11
+
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+
14
+ interface CSSCollectorStore {
15
+ imports: Set<string>;
16
+ }
17
+
18
+ const cssStorage = new AsyncLocalStorage<CSSCollectorStore>();
19
+
20
+ /**
21
+ * Run a function with CSS import collection enabled.
22
+ * Returns the function result and all collected CSS import paths.
23
+ */
24
+ export async function runWithCSSCollector<T>(
25
+ fn: () => T | Promise<T>,
26
+ ): Promise<{ result: T; cssImports: string[] }> {
27
+ const store: CSSCollectorStore = { imports: new Set() };
28
+ const result = await cssStorage.run(store, fn);
29
+ return { result, cssImports: [...store.imports] };
30
+ }
31
+
32
+ /**
33
+ * Register a CSS import path discovered during module loading.
34
+ * No-op if called outside of a runWithCSSCollector context.
35
+ */
36
+ export function registerCSSImport(absolutePath: string): void {
37
+ const store = cssStorage.getStore();
38
+ if (!store) return;
39
+ store.imports.add(absolutePath);
40
+ }
41
+
42
+ /**
43
+ * Get all CSS imports collected so far in the current context.
44
+ * Returns empty array if called outside of a runWithCSSCollector context.
45
+ */
46
+ export function getCSSImports(): string[] {
47
+ const store = cssStorage.getStore();
48
+ if (!store) return [];
49
+ return [...store.imports];
50
+ }
@@ -52,6 +52,7 @@ import { SSRCircuitBreaker } from "./ssr-circuit-breaker.js";
52
52
  import { SSRDependencyValidator } from "./ssr-dependency-validator.js";
53
53
  import { preflightLocalImports } from "./preflight-imports.js";
54
54
  import { resolveVfModuleImports } from "./vf-module-resolver.js";
55
+ import { registerCSSImport } from "../css-import-collector.js";
55
56
 
56
57
  const logger = rendererLogger.component("ssr-module-loader");
57
58
 
@@ -480,6 +481,12 @@ export class SSRModuleLoader {
480
481
  this.options.adapter,
481
482
  );
482
483
 
484
+ // Register CSS imports for later inclusion in HTML output.
485
+ // CSS files are not JS modules — skip them in the dependency graph.
486
+ for (const cssImport of parseResult.cssImports) {
487
+ registerCSSImport(cssImport.absolutePath);
488
+ }
489
+
483
490
  if (parseResult.missing.length > 0) {
484
491
  this.depValidator.missingDependencies.push(...parseResult.missing);
485
492
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { CrossProjectImport, MissingImport } from "../../../transforms/esm/import-parser.js";
11
11
  import { parseLocalImports } from "../../../transforms/esm/import-parser.js";
12
+ import { registerCSSImport } from "../css-import-collector.js";
12
13
  import { createFileSystem } from "../../../platform/compat/fs.js";
13
14
  import { createError, toError } from "../../../errors/veryfront-error.js";
14
15
  import { rendererLogger } from "../../../utils/index.js";
@@ -92,6 +93,11 @@ export class SSRDependencyValidator {
92
93
  this.adapter,
93
94
  );
94
95
 
96
+ // Register CSS imports from cached modules for HTML inclusion
97
+ for (const cssImport of parseResult.cssImports) {
98
+ registerCSSImport(cssImport.absolutePath);
99
+ }
100
+
95
101
  if (parseResult.missing.length > 0) {
96
102
  this.missingDependencies.push(...parseResult.missing);
97
103
  }
@@ -19,6 +19,7 @@ import {
19
19
  } from "../../../rendering/snippet-renderer.js";
20
20
  import { clearRendererCacheForProject } from "../../../rendering/renderer.js";
21
21
  import { invalidateProjectCSS } from "../../../html/styles-builder/tailwind-compiler.js";
22
+ import { invalidateProjectCandidateManifests } from "../../../rendering/orchestrator/css-candidate-manifest.js";
22
23
 
23
24
  export function createFSAdapter(config: FSAdapterConfig): Promise<FSAdapter> {
24
25
  const type = config.type ?? "local";
@@ -51,7 +52,10 @@ export function createFSAdapter(config: FSAdapterConfig): Promise<FSAdapter> {
51
52
  clearRouterDetectionCacheForProject,
52
53
  clearSnippetCacheForProject,
53
54
  clearRendererCacheForProject,
54
- clearProjectCSSCache: invalidateProjectCSS,
55
+ clearProjectCSSCache: (projectSlug: string) => {
56
+ invalidateProjectCSS(projectSlug);
57
+ invalidateProjectCandidateManifests(projectSlug);
58
+ },
55
59
  ...config.invalidationCallbacks,
56
60
  },
57
61
  };
@@ -54,6 +54,7 @@ export class WebSocketManager {
54
54
 
55
55
  private wsConnectionId: string | null = null;
56
56
  private wsConsecutiveFailures = 0;
57
+ private disposed = false;
57
58
  private pokeMetrics = {
58
59
  received: 0,
59
60
  invalidationsTriggered: 0,
@@ -72,6 +73,8 @@ export class WebSocketManager {
72
73
  }
73
74
 
74
75
  connect(projectId: string): void {
76
+ if (this.disposed) return;
77
+
75
78
  this.cleanupTimers();
76
79
 
77
80
  if (this.wsConsecutiveFailures >= WS_RECONNECT_MAX_FAILURES) {
@@ -118,21 +121,28 @@ export class WebSocketManager {
118
121
  };
119
122
 
120
123
  this.ws.onclose = () => {
124
+ this.wsConnectionId = null;
125
+ this.cleanupTimers();
126
+
127
+ if (this.disposed) return;
128
+
121
129
  this.wsConsecutiveFailures++;
122
130
  const delay = this.getReconnectDelay();
123
131
  logger.debug("WebSocket closed, reconnecting", {
124
132
  delayMs: delay,
125
- connectionId: this.wsConnectionId,
126
133
  totalPokesReceived: this.pokeMetrics.received,
127
134
  consecutiveFailures: this.wsConsecutiveFailures,
128
135
  });
129
- this.wsConnectionId = null;
130
- this.cleanupTimers();
131
136
  this.wsReconnectTimer = dntShim.setTimeout(() => this.connect(projectId), delay);
132
137
  };
133
138
 
134
- this.ws.onerror = (error) => {
135
- logger.warn("WebSocket error", { error });
139
+ this.ws.onerror = (event) => {
140
+ logger.warn("WebSocket error", {
141
+ type: event.type,
142
+ url: (event.target as WebSocket)?.url?.replace(/token=[^&]+/, "token=***"),
143
+ readyState: (event.target as WebSocket)?.readyState,
144
+ consecutiveFailures: this.wsConsecutiveFailures,
145
+ });
136
146
  };
137
147
  } catch (error) {
138
148
  this.wsConsecutiveFailures++;
@@ -152,6 +162,7 @@ export class WebSocketManager {
152
162
  }
153
163
 
154
164
  dispose(): void {
165
+ this.disposed = true;
155
166
  this.cleanupTimers();
156
167
 
157
168
  if (this.invalidationTimer) {
@@ -166,6 +177,11 @@ export class WebSocketManager {
166
177
 
167
178
  if (!this.ws) return;
168
179
 
180
+ // Detach handlers before closing to prevent onclose from scheduling a reconnect
181
+ this.ws.onclose = null;
182
+ this.ws.onerror = null;
183
+ this.ws.onmessage = null;
184
+
169
185
  try {
170
186
  this.ws.close();
171
187
  } catch (error) {
@@ -361,7 +361,10 @@ export function getOsType(): string {
361
361
  /**
362
362
  * Register a signal handler (SIGINT, SIGTERM) for graceful shutdown
363
363
  */
364
- export function onSignal(signal: "SIGINT" | "SIGTERM", handler: () => void): void {
364
+ export function onSignal(
365
+ signal: "SIGINT" | "SIGTERM",
366
+ handler: () => void,
367
+ ): void {
365
368
  if (IS_DENO) {
366
369
  dntShim.Deno.addSignalListener(signal, handler);
367
370
  return;
@@ -397,14 +400,35 @@ export function onGlobalError(
397
400
 
398
401
  if (!hasNodeProcess) return;
399
402
 
403
+ const handleNodeGlobalError = (
404
+ error: Error,
405
+ type: "uncaughtException" | "unhandledRejection",
406
+ ): void => {
407
+ let shouldPreventExit = false;
408
+ try {
409
+ shouldPreventExit = onError(error, type) === true;
410
+ } catch (handlerError) {
411
+ const handlerException = handlerError instanceof Error
412
+ ? handlerError
413
+ : new Error(String(handlerError));
414
+ console.error("Global error handler threw while processing", type, handlerException);
415
+ }
416
+
417
+ if (shouldPreventExit) return;
418
+
419
+ // Node/Bun suppress default fatal behavior when a listener is registered.
420
+ // If the callback did not explicitly handle the error, exit to preserve
421
+ // expected fatal semantics for uncaught exceptions and unhandled rejections.
422
+ nodeProcess!.exit(1);
423
+ };
424
+
400
425
  nodeProcess!.on("uncaughtException", (error: Error) => {
401
- onError(error, "uncaughtException");
402
- // Note: In Node.js, uncaughtException doesn't exit by default if handler is registered
426
+ handleNodeGlobalError(error, "uncaughtException");
403
427
  });
404
428
 
405
429
  nodeProcess!.on("unhandledRejection", (reason: unknown) => {
406
430
  const error = reason instanceof Error ? reason : new Error(String(reason));
407
- onError(error, "unhandledRejection");
431
+ handleNodeGlobalError(error, "unhandledRejection");
408
432
  });
409
433
  }
410
434
 
@@ -581,19 +581,40 @@ function router(req: dntShim.Request): Promise<dntShim.Response> {
581
581
  return forwardToServer(req);
582
582
  }
583
583
 
584
+ // Create server before signal registration so early SIGTERM/SIGINT can close it safely.
585
+ const server = createHttpServer();
586
+
584
587
  // Graceful shutdown
585
- async function shutdown(): Promise<void> {
586
- proxyLogger.info("Shutting down");
587
- rendererRouter?.close();
588
- serverResolver.close();
589
- await proxyHandler.close();
590
- await shutdownOTLP();
591
- proxyLogger.info("Closed connections");
592
- exit(0);
588
+ let shuttingDown = false;
589
+ async function shutdown(signal: "SIGINT" | "SIGTERM"): Promise<void> {
590
+ if (shuttingDown) return;
591
+ shuttingDown = true;
592
+
593
+ proxyLogger.info(`Received ${signal}, shutting down`);
594
+
595
+ try {
596
+ await server.close();
597
+ rendererRouter?.close();
598
+ serverResolver.close();
599
+ await proxyHandler.close();
600
+ await shutdownOTLP();
601
+ proxyLogger.info("Closed connections");
602
+ } catch (error) {
603
+ proxyLogger.error("Error while shutting down proxy", error);
604
+ } finally {
605
+ exit(0);
606
+ }
593
607
  }
594
608
 
595
- onSignal("SIGINT", shutdown);
596
- onSignal("SIGTERM", shutdown);
609
+ const handleSignal = (signal: "SIGINT" | "SIGTERM"): void => {
610
+ void shutdown(signal).catch((error) => {
611
+ proxyLogger.error("Unhandled shutdown error", { signal }, error);
612
+ exit(1);
613
+ });
614
+ };
615
+
616
+ onSignal("SIGINT", () => handleSignal("SIGINT"));
617
+ onSignal("SIGTERM", () => handleSignal("SIGTERM"));
597
618
 
598
619
  // Wait for sticky-session router to resolve initial target list
599
620
  await rendererRouter?.ready();
@@ -607,6 +628,5 @@ proxyLogger.debug("Starting proxy server (split mode)", {
607
628
  apiBaseUrl: config.apiBaseUrl,
608
629
  });
609
630
 
610
- // Create and start the HTTP server
611
- const server = createHttpServer();
631
+ // Start the HTTP server
612
632
  await server.serve(router, { port: PORT, hostname: HOST });
@@ -5,6 +5,15 @@ import { ProxySpanNames, withSpan } from "./tracing.js";
5
5
 
6
6
  export type TokenScope = "preview" | "production";
7
7
 
8
+ interface NegativeCacheEntry {
9
+ status: number;
10
+ message: string;
11
+ cachedAt: number;
12
+ }
13
+
14
+ const NEGATIVE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
15
+ const NEGATIVE_CACHE_MAX_SIZE = 1000;
16
+
8
17
  export interface OAuthConfig {
9
18
  apiBaseUrl: string;
10
19
  apiClientId: string;
@@ -21,6 +30,7 @@ export interface TokenManagerOptions {
21
30
  export class TokenManager {
22
31
  private cache: TokenCache;
23
32
  private pendingRequests = new Map<string, Promise<string>>();
33
+ private negativeCache = new Map<string, NegativeCacheEntry>();
24
34
  private refreshBuffer: number;
25
35
 
26
36
  constructor(
@@ -42,6 +52,14 @@ export class TokenManager {
42
52
  return withSpan(
43
53
  ProxySpanNames.PROXY_TOKEN_FETCH,
44
54
  async () => {
55
+ const negEntry = this.negativeCache.get(cacheKey);
56
+ if (negEntry) {
57
+ if (Date.now() - negEntry.cachedAt < NEGATIVE_CACHE_TTL) {
58
+ throw new Error(negEntry.message);
59
+ }
60
+ this.negativeCache.delete(cacheKey);
61
+ }
62
+
45
63
  const cached = await this.cache.get(cacheKey);
46
64
  if (cached && this.isTokenValid(cached)) return cached.token;
47
65
 
@@ -67,10 +85,13 @@ export class TokenManager {
67
85
  }
68
86
 
69
87
  async invalidateToken(scope: TokenScope, projectSlug?: string): Promise<void> {
70
- await this.cache.delete(this.getCacheKey(scope, projectSlug));
88
+ const cacheKey = this.getCacheKey(scope, projectSlug);
89
+ this.negativeCache.delete(cacheKey);
90
+ await this.cache.delete(cacheKey);
71
91
  }
72
92
 
73
93
  async clearCache(): Promise<void> {
94
+ this.negativeCache.clear();
74
95
  await this.cache.clear();
75
96
  }
76
97
 
@@ -101,13 +122,32 @@ export class TokenManager {
101
122
  ? this.config.previewApiClientSecret
102
123
  : this.config.apiClientSecret;
103
124
 
104
- const response = await fetchOAuthToken({
105
- apiBaseUrl: this.config.apiBaseUrl,
106
- apiClientId,
107
- apiClientSecret,
108
- projectSlug,
109
- customDomain,
110
- });
125
+ let response: TokenResponse;
126
+ try {
127
+ response = await fetchOAuthToken({
128
+ apiBaseUrl: this.config.apiBaseUrl,
129
+ apiClientId,
130
+ apiClientSecret,
131
+ projectSlug,
132
+ customDomain,
133
+ });
134
+ } catch (error) {
135
+ const status = this.parseStatusFromError(error);
136
+ if (status === 400 || status === 404) {
137
+ const projectKey = projectSlug || customDomain;
138
+ const cacheKey = this.getCacheKey(scope, projectKey);
139
+ if (this.negativeCache.size >= NEGATIVE_CACHE_MAX_SIZE) {
140
+ const oldest = this.negativeCache.keys().next().value;
141
+ if (oldest !== undefined) this.negativeCache.delete(oldest);
142
+ }
143
+ this.negativeCache.set(cacheKey, {
144
+ status,
145
+ message: error instanceof Error ? error.message : String(error),
146
+ cachedAt: Date.now(),
147
+ });
148
+ }
149
+ throw error;
150
+ }
111
151
 
112
152
  const projectKey = projectSlug || customDomain;
113
153
 
@@ -121,6 +161,12 @@ export class TokenManager {
121
161
  return response.access_token;
122
162
  }
123
163
 
164
+ private parseStatusFromError(error: unknown): number | null {
165
+ const message = error instanceof Error ? error.message : String(error);
166
+ const match = message.match(/failed: (\d+)/);
167
+ return match ? Number(match[1]) : null;
168
+ }
169
+
124
170
  private calculateExpiresAt(response: TokenResponse): number {
125
171
  if (response.expires_in) return Date.now() + response.expires_in * 1000;
126
172