veryfront 0.0.84 → 0.0.86
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.
- package/README.md +18 -44
- package/esm/deno.js +1 -1
- package/esm/src/cli/app/components/list-select.js +2 -2
- package/esm/src/cli/app/index.d.ts +1 -1
- package/esm/src/cli/app/index.d.ts.map +1 -1
- package/esm/src/cli/app/index.js +26 -33
- package/esm/src/cli/app/state.d.ts +3 -0
- package/esm/src/cli/app/state.d.ts.map +1 -1
- package/esm/src/cli/app/state.js +4 -0
- package/esm/src/cli/app/views/dashboard.d.ts.map +1 -1
- package/esm/src/cli/app/views/dashboard.js +45 -57
- package/esm/src/cli/app/views/startup.d.ts +39 -0
- package/esm/src/cli/app/views/startup.d.ts.map +1 -0
- package/esm/src/cli/app/views/startup.js +103 -0
- package/esm/src/cli/ui/colors.d.ts +8 -0
- package/esm/src/cli/ui/colors.d.ts.map +1 -1
- package/esm/src/cli/ui/colors.js +34 -0
- package/esm/src/cli/ui/dot-matrix.d.ts +8 -0
- package/esm/src/cli/ui/dot-matrix.d.ts.map +1 -1
- package/esm/src/cli/ui/dot-matrix.js +67 -2
- package/esm/src/modules/react-loader/ssr-module-loader/loader.d.ts.map +1 -1
- package/esm/src/modules/react-loader/ssr-module-loader/loader.js +28 -6
- package/esm/src/transforms/esm/http-cache.d.ts.map +1 -1
- package/esm/src/transforms/esm/http-cache.js +25 -17
- package/package.json +1 -1
- package/src/deno.js +1 -1
- package/src/src/cli/app/components/list-select.ts +2 -2
- package/src/src/cli/app/index.ts +34 -35
- package/src/src/cli/app/state.ts +7 -0
- package/src/src/cli/app/views/dashboard.ts +46 -60
- package/src/src/cli/app/views/startup.ts +132 -0
- package/src/src/cli/ui/colors.ts +37 -0
- package/src/src/cli/ui/dot-matrix.ts +77 -1
- package/src/src/modules/react-loader/ssr-module-loader/loader.ts +43 -30
- package/src/src/transforms/esm/http-cache.ts +26 -17
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { box } from "../../ui/box.js";
|
|
8
|
-
import { brand, dim, error, muted
|
|
8
|
+
import { brand, dim, error, muted } from "../../ui/colors.js";
|
|
9
9
|
import { getTerminalWidth } from "../../ui/layout.js";
|
|
10
10
|
import { getAgentFaceWithText } from "../../ui/dot-matrix.js";
|
|
11
11
|
import { renderList } from "../components/list-select.js";
|
|
@@ -27,7 +27,7 @@ export function renderDashboard(state: AppState): string {
|
|
|
27
27
|
|
|
28
28
|
if (hasProjects) {
|
|
29
29
|
const isActive = state.activeList === "projects";
|
|
30
|
-
lines.push(renderSection("Local
|
|
30
|
+
lines.push(renderSection("Local", state.projects.items.length, isActive));
|
|
31
31
|
lines.push(
|
|
32
32
|
renderList(state.projects, {
|
|
33
33
|
maxWidth: maxListWidth,
|
|
@@ -47,10 +47,10 @@ export function renderDashboard(state: AppState): string {
|
|
|
47
47
|
const end = Math.min(start + visibleCount, state.remote.projects.length);
|
|
48
48
|
const visibleProjects = state.remote.projects.slice(start, end);
|
|
49
49
|
|
|
50
|
-
lines.push(renderSection("Remote
|
|
50
|
+
lines.push(renderSection("Remote", state.remote.projects.length, isRemoteActive));
|
|
51
51
|
|
|
52
52
|
if (start > 0) {
|
|
53
|
-
lines.push(` ${dim("
|
|
53
|
+
lines.push(` ${dim("↑")} ${dim("more above")}`);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
visibleProjects.forEach((p, i) => {
|
|
@@ -68,7 +68,7 @@ export function renderDashboard(state: AppState): string {
|
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
if (end < state.remote.projects.length) {
|
|
71
|
-
lines.push(` ${dim("
|
|
71
|
+
lines.push(` ${dim("↓")} ${dim("more below")}`);
|
|
72
72
|
}
|
|
73
73
|
lines.push("");
|
|
74
74
|
}
|
|
@@ -93,27 +93,26 @@ export function renderDashboard(state: AppState): string {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Render the banner with agent face and server info
|
|
96
|
+
* Render the banner with agent face and server info inside a box
|
|
97
97
|
*/
|
|
98
98
|
function renderBanner(state: AppState): string {
|
|
99
|
-
const
|
|
100
|
-
const mcpDot = state.mcp.enabled ? success("●") : dim("○");
|
|
101
|
-
|
|
99
|
+
const termWidth = Math.min(getTerminalWidth() - 4, 80);
|
|
102
100
|
const textLines: string[] = [];
|
|
103
101
|
|
|
104
|
-
textLines.push(
|
|
105
|
-
textLines.push(
|
|
102
|
+
textLines.push("");
|
|
103
|
+
textLines.push(`${brand("Veryfront Code")} ${dim("is now running")}`);
|
|
104
|
+
textLines.push("");
|
|
106
105
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
106
|
+
// Server URL and MCP URL - always reserve both lines to prevent jumps
|
|
107
|
+
textLines.push(`${dim("Url")} ${brand(state.server.url)}`);
|
|
108
|
+
if (state.mcp.enabled && state.mcp.transport === "http") {
|
|
109
|
+
const port = state.mcp.httpPort ?? 9999;
|
|
110
|
+
textLines.push(`${dim("Mcp")} ${brand(`http://veryfront.me:${port}/mcp`)}`);
|
|
111
|
+
} else {
|
|
112
|
+
textLines.push("");
|
|
115
113
|
}
|
|
116
114
|
|
|
115
|
+
// Errors/warnings on separate line if any
|
|
117
116
|
const { errors, warnings } = state.server;
|
|
118
117
|
if (errors > 0 || warnings > 0) {
|
|
119
118
|
const parts: string[] = [];
|
|
@@ -122,58 +121,56 @@ function renderBanner(state: AppState): string {
|
|
|
122
121
|
textLines.push(parts.join(" "));
|
|
123
122
|
}
|
|
124
123
|
|
|
125
|
-
|
|
124
|
+
// Pad to 7 text lines (matching avatar height) for consistent title position
|
|
125
|
+
while (textLines.length < 7) {
|
|
126
|
+
textLines.push("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const content = getAgentFaceWithText(textLines, {
|
|
126
130
|
litColor: "\x1b[38;2;252;143;93m", // Veryfront brand orange
|
|
127
131
|
});
|
|
132
|
+
|
|
133
|
+
return box(content, {
|
|
134
|
+
style: "rounded",
|
|
135
|
+
width: termWidth,
|
|
136
|
+
paddingX: 2,
|
|
137
|
+
paddingY: 1,
|
|
138
|
+
borderColor: "\x1b[2m", // Dim to match footer
|
|
139
|
+
});
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
/**
|
|
131
143
|
* Render a section header
|
|
132
144
|
*/
|
|
133
|
-
function renderSection(title: string,
|
|
145
|
+
function renderSection(title: string, _count: number, isActive = true): string {
|
|
134
146
|
const indicator = isActive ? brand("›") : " ";
|
|
135
147
|
const titleText = isActive ? title : dim(title);
|
|
136
|
-
return ` ${indicator} ${titleText}
|
|
148
|
+
return ` ${indicator} ${titleText}`;
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
/**
|
|
140
152
|
* Render the help bar at the bottom
|
|
141
153
|
*/
|
|
142
154
|
function renderHelpBar(state: AppState): string {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const hasRemoteProjects = state.remote.user && state.remote.projects.length > 0;
|
|
148
|
-
|
|
149
|
-
// Count sections for tab switching
|
|
150
|
-
const sectionCount = [hasProjects, hasExamples, hasRemoteProjects].filter(Boolean).length;
|
|
151
|
-
|
|
152
|
-
if (sectionCount > 1) {
|
|
153
|
-
parts.push(dim("tab switch"));
|
|
155
|
+
// Minimal by default, ? reveals all
|
|
156
|
+
if (!state.showHelp) {
|
|
157
|
+
const userInfo = state.remote.user ? ` ${dim("-")} ${brand(state.remote.user.email)}` : "";
|
|
158
|
+
return ` ${dim("↑↓ select enter open ? more q quit")}${userInfo}`;
|
|
154
159
|
}
|
|
155
160
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
parts.push(dim("o open"), dim("s studio"), dim("i ide"));
|
|
160
|
-
}
|
|
161
|
+
// Expanded help
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
lines.push(` ${dim("o")} open ${dim("s")} studio ${dim("i")} ide`);
|
|
161
164
|
|
|
162
165
|
if (!state.remote.user) {
|
|
163
|
-
|
|
166
|
+
lines.push(` ${dim("n")} new ${dim("a")} login`);
|
|
164
167
|
} else {
|
|
165
|
-
|
|
166
|
-
if (state.activeList === "projects") {
|
|
167
|
-
parts.push(dim("p pull"), dim("u push"));
|
|
168
|
-
} else if (state.activeList === "remoteProjects") {
|
|
169
|
-
parts.push(dim("p pull"));
|
|
170
|
-
}
|
|
171
|
-
parts.push(dim("n new"), dim("x logout"));
|
|
168
|
+
lines.push(` ${dim("n")} new ${dim("p")} pull ${dim("u")} push ${dim("x")} logout`);
|
|
172
169
|
}
|
|
173
170
|
|
|
174
|
-
|
|
171
|
+
lines.push(` ${dim("? hide q quit")}`);
|
|
175
172
|
|
|
176
|
-
return
|
|
173
|
+
return lines.join("\n");
|
|
177
174
|
}
|
|
178
175
|
|
|
179
176
|
/**
|
|
@@ -197,16 +194,5 @@ export function renderDashboardBoxed(state: AppState): string {
|
|
|
197
194
|
* Render empty state when no projects found
|
|
198
195
|
*/
|
|
199
196
|
export function renderEmptyState(): string {
|
|
200
|
-
return
|
|
201
|
-
"",
|
|
202
|
-
` ${dim("No projects found.")}`,
|
|
203
|
-
"",
|
|
204
|
-
` ${dim("Get started:")}`,
|
|
205
|
-
` ${brand("[n]")} Create a new project`,
|
|
206
|
-
` ${brand("[t]")} Browse templates`,
|
|
207
|
-
"",
|
|
208
|
-
` ${dim("Or run with a project directory:")}`,
|
|
209
|
-
` ${muted("deno task start --project ./my-project")}`,
|
|
210
|
-
"",
|
|
211
|
-
].join("\n");
|
|
197
|
+
return `\n ${dim("No projects.")} ${brand("n")} ${dim("to create")}\n`;
|
|
212
198
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup View
|
|
3
|
+
*
|
|
4
|
+
* Shows loading progress with consistent box sizing.
|
|
5
|
+
* Displays avatar, title, and step checklist.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { box } from "../../ui/box.js";
|
|
9
|
+
import { brand, dim, shimmer } from "../../ui/colors.js";
|
|
10
|
+
|
|
11
|
+
// Dim orange for completed steps - matches the trailing dots in spinning animation
|
|
12
|
+
const dimOrange = (text: string) => `\x1b[38;2;180;100;65m${text}\x1b[0m`;
|
|
13
|
+
import { getTerminalWidth } from "../../ui/layout.js";
|
|
14
|
+
import { getAgentFaceWithText, getSpinningAgentFace } from "../../ui/dot-matrix.js";
|
|
15
|
+
|
|
16
|
+
export interface StartupStep {
|
|
17
|
+
label: string;
|
|
18
|
+
status: "pending" | "active" | "done";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StartupState {
|
|
22
|
+
steps: StartupStep[];
|
|
23
|
+
serverUrl?: string;
|
|
24
|
+
mcpUrl?: string;
|
|
25
|
+
ready: boolean;
|
|
26
|
+
/** Animation frame counter for shimmer effect */
|
|
27
|
+
frame: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render the startup view inside a consistent-sized box
|
|
32
|
+
*/
|
|
33
|
+
export function renderStartup(state: StartupState): string {
|
|
34
|
+
const termWidth = Math.min(getTerminalWidth() - 4, 80);
|
|
35
|
+
const textLines: string[] = [];
|
|
36
|
+
|
|
37
|
+
if (state.ready) {
|
|
38
|
+
// Running state - always reserve space for both URL lines to prevent jumps
|
|
39
|
+
textLines.push("");
|
|
40
|
+
textLines.push(`${brand("Veryfront Code")} ${dim("is now running")}`);
|
|
41
|
+
textLines.push("");
|
|
42
|
+
textLines.push(state.serverUrl ? `${dim("Url")} ${brand(state.serverUrl)}` : "");
|
|
43
|
+
textLines.push(state.mcpUrl ? `${dim("Mcp")} ${brand(state.mcpUrl)}` : "");
|
|
44
|
+
} else {
|
|
45
|
+
// Loading state - match ready state layout
|
|
46
|
+
textLines.push("");
|
|
47
|
+
textLines.push(`${brand("Veryfront Code")} ${dim("starting...")}`);
|
|
48
|
+
textLines.push("");
|
|
49
|
+
|
|
50
|
+
for (const step of state.steps) {
|
|
51
|
+
if (step.status === "done") {
|
|
52
|
+
// Completed: dim orange (fades into background, coherent with avatar)
|
|
53
|
+
textLines.push(`${dimOrange("●")} ${dimOrange(step.label)}`);
|
|
54
|
+
} else if (step.status === "active") {
|
|
55
|
+
// Active: bright orange dot with shimmer text
|
|
56
|
+
textLines.push(`${brand("●")} ${shimmer(step.label, state.frame)}`);
|
|
57
|
+
} else {
|
|
58
|
+
// Pending: gray empty circle
|
|
59
|
+
textLines.push(`${dim("○")} ${dim(step.label)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pad to 7 text lines (matching avatar height) for consistent title position
|
|
65
|
+
while (textLines.length < 7) {
|
|
66
|
+
textLines.push("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Use spinning avatar during loading, static when ready or all steps done
|
|
70
|
+
const allStepsDone = state.steps.every((s) => s.status === "done");
|
|
71
|
+
const content = state.ready || allStepsDone
|
|
72
|
+
? getAgentFaceWithText(textLines, {
|
|
73
|
+
litColor: "\x1b[38;2;252;143;93m", // Veryfront brand orange
|
|
74
|
+
})
|
|
75
|
+
: getSpinningAgentFace(textLines, state.frame, {
|
|
76
|
+
litColor: "\x1b[38;2;252;143;93m", // Veryfront brand orange
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return box(content, {
|
|
80
|
+
style: "rounded",
|
|
81
|
+
width: termWidth,
|
|
82
|
+
paddingX: 2,
|
|
83
|
+
paddingY: 1,
|
|
84
|
+
borderColor: "\x1b[2m", // Dim to match footer
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create initial startup state with steps
|
|
90
|
+
*/
|
|
91
|
+
export function createStartupState(stepLabels: string[]): StartupState {
|
|
92
|
+
return {
|
|
93
|
+
steps: stepLabels.map((label) => ({ label, status: "pending" })),
|
|
94
|
+
ready: false,
|
|
95
|
+
frame: 0,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Increment animation frame for shimmer effect
|
|
101
|
+
*/
|
|
102
|
+
export function incrementFrame(state: StartupState): StartupState {
|
|
103
|
+
return { ...state, frame: state.frame + 1 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set a step to active
|
|
108
|
+
*/
|
|
109
|
+
export function setStepActive(state: StartupState, index: number): StartupState {
|
|
110
|
+
const steps = state.steps.map((step, i) => ({
|
|
111
|
+
...step,
|
|
112
|
+
status: i < index ? "done" : i === index ? "active" : "pending",
|
|
113
|
+
})) as StartupStep[];
|
|
114
|
+
|
|
115
|
+
return { ...state, steps };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Mark all steps done and set ready
|
|
120
|
+
*/
|
|
121
|
+
export function setStartupReady(
|
|
122
|
+
state: StartupState,
|
|
123
|
+
serverUrl: string,
|
|
124
|
+
mcpUrl?: string,
|
|
125
|
+
): StartupState {
|
|
126
|
+
const steps = state.steps.map((step) => ({
|
|
127
|
+
...step,
|
|
128
|
+
status: "done" as const,
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
return { ...state, steps, serverUrl, mcpUrl, ready: true };
|
|
132
|
+
}
|
package/src/src/cli/ui/colors.ts
CHANGED
|
@@ -157,3 +157,40 @@ export function animatedMatrix(frame: number): string {
|
|
|
157
157
|
const state = MATRIX_STATES[frame % MATRIX_STATES.length] ?? ["●", "○", "○"];
|
|
158
158
|
return state.map((dot) => (dot === "●" ? brand(dot) : muted(dot))).join("");
|
|
159
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Apply shimmer effect to text - creates a wave of brightness moving across
|
|
163
|
+
* @param text The text to shimmer
|
|
164
|
+
* @param frame Current animation frame (increments over time)
|
|
165
|
+
* @param waveWidth Width of the bright wave (default: 3 characters)
|
|
166
|
+
* @returns Text with shimmer effect applied
|
|
167
|
+
*/
|
|
168
|
+
export function shimmer(text: string, frame: number, waveWidth = 3): string {
|
|
169
|
+
// Brand orange gradient: bright → normal → dim
|
|
170
|
+
const bright = (char: string) => applyColor(char, 255, 180, 140, false); // Brighter orange
|
|
171
|
+
const normal = (char: string) => applyColor(char, 252, 143, 93, false); // Brand orange
|
|
172
|
+
const dimmed = (char: string) => applyColor(char, 180, 100, 65, false); // Dimmer orange
|
|
173
|
+
|
|
174
|
+
const len = text.length;
|
|
175
|
+
const wavePos = frame % (len + waveWidth * 2);
|
|
176
|
+
|
|
177
|
+
let result = "";
|
|
178
|
+
for (let i = 0; i < len; i++) {
|
|
179
|
+
const char = text[i]!;
|
|
180
|
+
const distFromWave = i - (wavePos - waveWidth);
|
|
181
|
+
|
|
182
|
+
if (distFromWave >= 0 && distFromWave < waveWidth) {
|
|
183
|
+
// In the bright wave
|
|
184
|
+
const intensity = 1 - Math.abs(distFromWave - waveWidth / 2) / (waveWidth / 2);
|
|
185
|
+
if (intensity > 0.6) {
|
|
186
|
+
result += bright(char);
|
|
187
|
+
} else {
|
|
188
|
+
result += normal(char);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
result += dimmed(char);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
@@ -76,6 +76,14 @@ const COMPACT_OPTIONS: Partial<DotMatrixOptions> = {
|
|
|
76
76
|
|
|
77
77
|
const RESET = "\x1b[0m";
|
|
78
78
|
|
|
79
|
+
// Colors for spinning animation - shades of orange
|
|
80
|
+
const SPIN_COLORS = {
|
|
81
|
+
bright: "\x1b[38;2;255;165;120m", // Bright orange (leading edge, toned down)
|
|
82
|
+
orange: "\x1b[38;2;252;143;93m", // Brand orange
|
|
83
|
+
mid: "\x1b[38;2;200;110;70m", // Mid orange (trailing)
|
|
84
|
+
dim: "\x1b[38;2;140;80;50m", // Dim orange/brown
|
|
85
|
+
};
|
|
86
|
+
|
|
79
87
|
function resolveOptions(options: DotMatrixOptions): Required<DotMatrixOptions> {
|
|
80
88
|
if (options.compact) return { ...DEFAULT_OPTIONS, ...COMPACT_OPTIONS, ...options };
|
|
81
89
|
return { ...DEFAULT_OPTIONS, ...options };
|
|
@@ -92,12 +100,65 @@ function renderPattern(pattern: number[][], opts: Required<DotMatrixOptions>): s
|
|
|
92
100
|
});
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Render the pattern with a spinning blade effect
|
|
105
|
+
* The blade sweeps across the lit dots, creating orange → purple gradient
|
|
106
|
+
*/
|
|
107
|
+
function renderSpinningPattern(
|
|
108
|
+
pattern: number[][],
|
|
109
|
+
frame: number,
|
|
110
|
+
opts: Required<DotMatrixOptions>,
|
|
111
|
+
): string[] {
|
|
112
|
+
const centerRow = 3;
|
|
113
|
+
const centerCol = 3;
|
|
114
|
+
const totalFrames = 16; // Full rotation in 16 frames
|
|
115
|
+
const bladeAngle = ((frame % totalFrames) / totalFrames) * Math.PI * 2;
|
|
116
|
+
|
|
117
|
+
return pattern.map((row, rowIdx) => {
|
|
118
|
+
const dots = row.map((dot, colIdx) => {
|
|
119
|
+
if (dot !== 1) {
|
|
120
|
+
return `${opts.offColor}${opts.offChar}${RESET}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Calculate angle from center to this dot
|
|
124
|
+
const dy = rowIdx - centerRow;
|
|
125
|
+
const dx = colIdx - centerCol;
|
|
126
|
+
let dotAngle = Math.atan2(dy, dx);
|
|
127
|
+
if (dotAngle < 0) dotAngle += Math.PI * 2;
|
|
128
|
+
|
|
129
|
+
// Calculate angular distance from the blade
|
|
130
|
+
let angleDiff = dotAngle - bladeAngle;
|
|
131
|
+
if (angleDiff < 0) angleDiff += Math.PI * 2;
|
|
132
|
+
if (angleDiff > Math.PI) angleDiff = Math.PI * 2 - angleDiff;
|
|
133
|
+
|
|
134
|
+
// Color based on angular distance from blade
|
|
135
|
+
const normalizedDiff = angleDiff / Math.PI; // 0 = at blade, 1 = opposite
|
|
136
|
+
let color: string;
|
|
137
|
+
if (normalizedDiff < 0.15) {
|
|
138
|
+
color = SPIN_COLORS.bright; // Leading edge - brightest orange
|
|
139
|
+
} else if (normalizedDiff < 0.35) {
|
|
140
|
+
color = SPIN_COLORS.orange; // Near blade - brand orange
|
|
141
|
+
} else if (normalizedDiff < 0.6) {
|
|
142
|
+
color = SPIN_COLORS.mid; // Trailing - mid orange
|
|
143
|
+
} else {
|
|
144
|
+
color = SPIN_COLORS.dim; // Far from blade - dim orange
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return `${color}${opts.litChar}${RESET}`;
|
|
148
|
+
});
|
|
149
|
+
return opts.prefix + dots.join(opts.spacing);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
95
153
|
function renderPatternWithText(
|
|
96
154
|
pattern: number[][],
|
|
97
155
|
textLines: string[],
|
|
98
156
|
opts: Required<DotMatrixOptions>,
|
|
157
|
+
spinFrame?: number,
|
|
99
158
|
): string {
|
|
100
|
-
const faceLines =
|
|
159
|
+
const faceLines = spinFrame !== undefined
|
|
160
|
+
? renderSpinningPattern(pattern, spinFrame, opts)
|
|
161
|
+
: renderPattern(pattern, opts);
|
|
101
162
|
|
|
102
163
|
const faceHeight = faceLines.length;
|
|
103
164
|
const startLine = Math.floor((faceHeight - textLines.length) / 2);
|
|
@@ -164,6 +225,21 @@ export function getAgentFaceWithText(
|
|
|
164
225
|
return renderPatternWithText(AGENT_FACE, textLines, resolveOptions(options));
|
|
165
226
|
}
|
|
166
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Get the agent face with spinning blade animation
|
|
230
|
+
* Orange and purple dots rotate around the logo
|
|
231
|
+
* @param textLines Text to show next to the face
|
|
232
|
+
* @param frame Animation frame (0-15 for full rotation)
|
|
233
|
+
* @param options Dot matrix options
|
|
234
|
+
*/
|
|
235
|
+
export function getSpinningAgentFace(
|
|
236
|
+
textLines: string[],
|
|
237
|
+
frame: number,
|
|
238
|
+
options: DotMatrixOptions = {},
|
|
239
|
+
): string {
|
|
240
|
+
return renderPatternWithText(AGENT_FACE, textLines, resolveOptions(options), frame);
|
|
241
|
+
}
|
|
242
|
+
|
|
167
243
|
/**
|
|
168
244
|
* Animated dot matrix display with spinner support
|
|
169
245
|
*/
|
|
@@ -143,24 +143,37 @@ export class SSRModuleLoader {
|
|
|
143
143
|
const bundleMatch = errorMsg.match(/veryfront-http-bundle\/http-([a-f0-9]+)\.mjs/);
|
|
144
144
|
if (bundleMatch) {
|
|
145
145
|
const hash = bundleMatch[1]!;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
const cacheDir = getHttpBundleCacheDir();
|
|
147
|
+
|
|
148
|
+
logger.error("[SSR-MODULE-LOADER] Missing HTTP bundle after ensureHttpBundlesExist", {
|
|
149
|
+
file: filePath.slice(-40),
|
|
150
|
+
hash,
|
|
151
|
+
tempPath: cacheEntry.tempPath,
|
|
152
|
+
contentHash: cacheEntry.contentHash,
|
|
153
|
+
cacheDir,
|
|
154
|
+
expectedPath: `${cacheDir}/http-${hash}.mjs`,
|
|
155
|
+
});
|
|
156
|
+
|
|
153
157
|
const { recoverHttpBundleByHash } = await import(
|
|
154
158
|
"../../../transforms/esm/http-cache.js"
|
|
155
159
|
);
|
|
156
|
-
const cacheDir = getHttpBundleCacheDir();
|
|
157
160
|
const recovered = await recoverHttpBundleByHash(hash, cacheDir);
|
|
158
161
|
if (recovered) {
|
|
159
|
-
logger.info("[SSR-MODULE-LOADER] HTTP bundle recovered, retrying import", {
|
|
162
|
+
logger.info("[SSR-MODULE-LOADER] HTTP bundle recovered, retrying import", {
|
|
163
|
+
hash,
|
|
164
|
+
file: filePath.slice(-40),
|
|
165
|
+
});
|
|
160
166
|
mod = await import(
|
|
161
167
|
`file://${cacheEntry.tempPath}?v=${cacheEntry.contentHash}&retry=1`
|
|
162
168
|
) as Record<string, unknown>;
|
|
163
169
|
} else {
|
|
170
|
+
logger.error("[SSR-MODULE-LOADER] HTTP bundle recovery failed", {
|
|
171
|
+
hash,
|
|
172
|
+
file: filePath.slice(-40),
|
|
173
|
+
cacheDir,
|
|
174
|
+
hint:
|
|
175
|
+
"Bundle may have expired from Redis (24h TTL) while transform was still cached",
|
|
176
|
+
});
|
|
164
177
|
throw importError;
|
|
165
178
|
}
|
|
166
179
|
} else {
|
|
@@ -444,13 +457,13 @@ export class SSRModuleLoader {
|
|
|
444
457
|
const cacheDir = getHttpBundleCacheDir();
|
|
445
458
|
const failed = await ensureHttpBundlesExist(bundlePaths, cacheDir);
|
|
446
459
|
if (failed.length > 0) {
|
|
447
|
-
logger.warn(
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
);
|
|
460
|
+
logger.warn("[SSR-MODULE-LOADER] Unrecoverable HTTP bundles, re-transforming", {
|
|
461
|
+
file: filePath.slice(-40),
|
|
462
|
+
failed,
|
|
463
|
+
totalBundles: bundlePaths.length,
|
|
464
|
+
cacheDir,
|
|
465
|
+
source: "memory-cache",
|
|
466
|
+
});
|
|
454
467
|
globalModuleCache.delete(contentCacheKey);
|
|
455
468
|
globalModuleCache.delete(filePathCacheKey);
|
|
456
469
|
// Fall through to Redis or fresh transform
|
|
@@ -487,13 +500,13 @@ export class SSRModuleLoader {
|
|
|
487
500
|
const cacheDir = getHttpBundleCacheDir();
|
|
488
501
|
const failed = await ensureHttpBundlesExist(bundlePaths, cacheDir);
|
|
489
502
|
if (failed.length > 0) {
|
|
490
|
-
logger.warn(
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
);
|
|
503
|
+
logger.warn("[SSR-MODULE-LOADER] Unrecoverable HTTP bundles, re-transforming", {
|
|
504
|
+
file: filePath.slice(-40),
|
|
505
|
+
failed,
|
|
506
|
+
totalBundles: bundlePaths.length,
|
|
507
|
+
cacheDir,
|
|
508
|
+
source: "redis-cache",
|
|
509
|
+
});
|
|
497
510
|
httpBundlesOk = false;
|
|
498
511
|
}
|
|
499
512
|
}
|
|
@@ -642,13 +655,13 @@ export class SSRModuleLoader {
|
|
|
642
655
|
const cacheDir = getHttpBundleCacheDir();
|
|
643
656
|
const failed = await ensureHttpBundlesExist(bundlePaths, cacheDir);
|
|
644
657
|
if (failed.length > 0) {
|
|
645
|
-
logger.warn(
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
);
|
|
658
|
+
logger.warn("[SSR-MODULE-LOADER] Unrecoverable HTTP bundles", {
|
|
659
|
+
file: filePath.slice(-40),
|
|
660
|
+
failed,
|
|
661
|
+
totalBundles: bundlePaths.length,
|
|
662
|
+
cacheDir,
|
|
663
|
+
source: "fresh-transform",
|
|
664
|
+
});
|
|
652
665
|
}
|
|
653
666
|
}
|
|
654
667
|
|
|
@@ -34,9 +34,6 @@ const getDistributedCache = createDistributedCacheAccessor(
|
|
|
34
34
|
"HTTP-CACHE",
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
/** TTL for cached modules in distributed cache (uses centralized config) */
|
|
38
|
-
const DISTRIBUTED_CACHE_TTL_SECONDS = HTTP_MODULE_DISTRIBUTED_TTL_SEC;
|
|
39
|
-
|
|
40
37
|
type CacheOptions = {
|
|
41
38
|
cacheDir: string;
|
|
42
39
|
importMap: ImportMapConfig;
|
|
@@ -47,6 +44,12 @@ type CacheOptions = {
|
|
|
47
44
|
const cachedPaths = new LRUCache<string, string>({ maxEntries: HTTP_MODULE_CACHE_MAX_ENTRIES });
|
|
48
45
|
const processingStack = new Set<string>();
|
|
49
46
|
|
|
47
|
+
/** Tracks last TTL refresh per hash. Refresh every 4h to keep 20h+ remaining (24h total). */
|
|
48
|
+
const lastDistributedRefresh = new LRUCache<string, number>({
|
|
49
|
+
maxEntries: HTTP_MODULE_CACHE_MAX_ENTRIES,
|
|
50
|
+
});
|
|
51
|
+
const DISTRIBUTED_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
|
|
52
|
+
|
|
50
53
|
function ensureAbsoluteDir(path: string): string {
|
|
51
54
|
return isAbsolute(path) ? path : join(cwd(), path);
|
|
52
55
|
}
|
|
@@ -194,22 +197,28 @@ async function cacheHttpModule(url: string, options: CacheOptions): Promise<stri
|
|
|
194
197
|
if (await exists(cachePath)) {
|
|
195
198
|
cachedPaths.set(cacheKey, cachePath);
|
|
196
199
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// Synchronous to guarantee data is stored before other pods need it
|
|
200
|
+
// Refresh distributed cache TTL so bundles outlive transforms that reference them.
|
|
201
|
+
// Without this, bundles expire (24h) while SSR transforms (6h) are still valid.
|
|
200
202
|
const distributed = await getDistributedCache();
|
|
201
203
|
if (distributed) {
|
|
202
204
|
const hash = simpleHash(normalizedUrl);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
205
|
+
const hashStr = String(hash);
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const lastRefresh = lastDistributedRefresh.get(hashStr);
|
|
208
|
+
const needsRefresh = !lastRefresh || (now - lastRefresh > DISTRIBUTED_REFRESH_INTERVAL_MS);
|
|
209
|
+
|
|
210
|
+
if (needsRefresh) {
|
|
211
|
+
try {
|
|
206
212
|
const code = await fs.readTextFile(cachePath);
|
|
207
|
-
await
|
|
208
|
-
|
|
213
|
+
await Promise.all([
|
|
214
|
+
distributed.set(`code:${hash}`, code, HTTP_MODULE_DISTRIBUTED_TTL_SEC),
|
|
215
|
+
distributed.set(`hash:${hash}`, normalizedUrl, HTTP_MODULE_DISTRIBUTED_TTL_SEC),
|
|
216
|
+
]);
|
|
217
|
+
lastDistributedRefresh.set(hashStr, now);
|
|
218
|
+
logger.debug("[HTTP-CACHE] Refreshed distributed cache TTL", { hash });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
logger.debug("[HTTP-CACHE] Distributed cache refresh failed", { hash, error });
|
|
209
221
|
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
// Log but don't fail - backfill is best-effort
|
|
212
|
-
logger.debug("[HTTP-CACHE] Backfill failed, continuing", { hash, error });
|
|
213
222
|
}
|
|
214
223
|
}
|
|
215
224
|
|
|
@@ -288,9 +297,9 @@ async function cacheHttpModule(url: string, options: CacheOptions): Promise<stri
|
|
|
288
297
|
const hash = simpleHash(normalizedUrl);
|
|
289
298
|
try {
|
|
290
299
|
await Promise.all([
|
|
291
|
-
distributed.set(normalizedUrl, code,
|
|
292
|
-
distributed.set(`code:${hash}`, code,
|
|
293
|
-
distributed.set(`hash:${hash}`, normalizedUrl,
|
|
300
|
+
distributed.set(normalizedUrl, code, HTTP_MODULE_DISTRIBUTED_TTL_SEC),
|
|
301
|
+
distributed.set(`code:${hash}`, code, HTTP_MODULE_DISTRIBUTED_TTL_SEC),
|
|
302
|
+
distributed.set(`hash:${hash}`, normalizedUrl, HTTP_MODULE_DISTRIBUTED_TTL_SEC),
|
|
294
303
|
]);
|
|
295
304
|
} catch (error) {
|
|
296
305
|
logger.debug("[HTTP-CACHE] Distributed cache set failed", { url: normalizedUrl, error });
|