blumenjs 0.2.2 → 0.2.3
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/dist/cli/commands/audit.js +204 -0
- package/dist/cli/commands/bench.js +227 -0
- package/dist/cli/commands/export.js +241 -0
- package/dist/cli/commands/migrate.js +267 -0
- package/dist/cli/commands/test.js +118 -0
- package/dist/templates/go-server/actions.go +147 -0
- package/dist/templates/go-server/redirects.go +203 -0
- package/dist/templates/go-server/ssg.go +230 -0
- package/package.json +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// cli/commands/migrate.ts
|
|
2
|
+
import * as fs2 from "fs";
|
|
3
|
+
import * as path2 from "path";
|
|
4
|
+
|
|
5
|
+
// cli/utils.ts
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
var c = {
|
|
10
|
+
reset: "\x1B[0m",
|
|
11
|
+
bold: "\x1B[1m",
|
|
12
|
+
dim: "\x1B[2m",
|
|
13
|
+
red: "\x1B[31m",
|
|
14
|
+
green: "\x1B[32m",
|
|
15
|
+
yellow: "\x1B[33m",
|
|
16
|
+
blue: "\x1B[34m",
|
|
17
|
+
magenta: "\x1B[35m",
|
|
18
|
+
cyan: "\x1B[36m",
|
|
19
|
+
white: "\x1B[37m",
|
|
20
|
+
gray: "\x1B[90m"
|
|
21
|
+
};
|
|
22
|
+
var log = {
|
|
23
|
+
info: (msg) => console.log(` ${c.magenta}\u25CF${c.reset} ${msg}`),
|
|
24
|
+
success: (msg) => console.log(` ${c.green}\u2713${c.reset} ${msg}`),
|
|
25
|
+
error: (msg) => console.error(` ${c.red}\u2717${c.reset} ${msg}`),
|
|
26
|
+
warn: (msg) => console.log(` ${c.yellow}\u26A0${c.reset} ${msg}`),
|
|
27
|
+
step: (msg) => console.log(` ${c.dim}\u2192${c.reset} ${msg}`),
|
|
28
|
+
blank: () => console.log("")
|
|
29
|
+
};
|
|
30
|
+
function getVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
33
|
+
let dir = path.dirname(thisFile);
|
|
34
|
+
for (let i = 0; i < 5; i++) {
|
|
35
|
+
const pkgFile = path.join(dir, "package.json");
|
|
36
|
+
if (fs.existsSync(pkgFile)) {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf-8"));
|
|
38
|
+
if (pkg.name === "blumenjs" || pkg.name === "go-react-ssr") {
|
|
39
|
+
return pkg.version;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
dir = path.dirname(dir);
|
|
43
|
+
}
|
|
44
|
+
return "0.0.0";
|
|
45
|
+
} catch {
|
|
46
|
+
return "0.0.0";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function banner() {
|
|
50
|
+
const version = getVersion();
|
|
51
|
+
console.log("");
|
|
52
|
+
console.log(
|
|
53
|
+
` ${c.magenta}${c.bold}\u{1F338} Blumen${c.reset} ${c.dim}v${version}${c.reset}`
|
|
54
|
+
);
|
|
55
|
+
console.log(
|
|
56
|
+
` ${c.dim}The React framework powered by Go${c.reset}`
|
|
57
|
+
);
|
|
58
|
+
console.log("");
|
|
59
|
+
}
|
|
60
|
+
function divider() {
|
|
61
|
+
console.log(` ${c.dim}${"\u2500".repeat(48)}${c.reset}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// cli/commands/migrate.ts
|
|
65
|
+
function isNextJsProject(dir) {
|
|
66
|
+
return fs2.existsSync(path2.join(dir, "next.config.js")) || fs2.existsSync(path2.join(dir, "next.config.mjs")) || fs2.existsSync(path2.join(dir, "next.config.ts")) || fs2.existsSync(path2.join(dir, "pages")) || fs2.existsSync(path2.join(dir, "src/pages"));
|
|
67
|
+
}
|
|
68
|
+
function findPagesDir(dir) {
|
|
69
|
+
if (fs2.existsSync(path2.join(dir, "pages")))
|
|
70
|
+
return path2.join(dir, "pages");
|
|
71
|
+
if (fs2.existsSync(path2.join(dir, "src/pages")))
|
|
72
|
+
return path2.join(dir, "src/pages");
|
|
73
|
+
if (fs2.existsSync(path2.join(dir, "app")))
|
|
74
|
+
return path2.join(dir, "app");
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function getAllTsxFiles(dir) {
|
|
78
|
+
const results = [];
|
|
79
|
+
if (!fs2.existsSync(dir))
|
|
80
|
+
return results;
|
|
81
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const fullPath = path2.join(dir, entry.name);
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
results.push(...getAllTsxFiles(fullPath));
|
|
86
|
+
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
87
|
+
results.push(fullPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
function rewriteImports(content, depth) {
|
|
93
|
+
const prefix = "../".repeat(depth) || "./";
|
|
94
|
+
const sharedPrefix = `${prefix}../shared`;
|
|
95
|
+
content = content.replace(
|
|
96
|
+
/import\s+(?:Link|{[^}]*})\s+from\s+['"]next\/link['"]/g,
|
|
97
|
+
`import { Link } from "${sharedPrefix}/Link"`
|
|
98
|
+
);
|
|
99
|
+
content = content.replace(
|
|
100
|
+
/import\s+(?:Head|{[^}]*})\s+from\s+['"]next\/head['"]/g,
|
|
101
|
+
`import { BlumenHead } from "${sharedPrefix}/BlumenHead"`
|
|
102
|
+
);
|
|
103
|
+
content = content.replace(
|
|
104
|
+
/import\s+(?:Image|{[^}]*})\s+from\s+['"]next\/image['"]/g,
|
|
105
|
+
`// TODO: Replace next/image with standard <img> or BlumenImage
|
|
106
|
+
// import Image from "next/image"`
|
|
107
|
+
);
|
|
108
|
+
content = content.replace(
|
|
109
|
+
/import\s+(?:{[^}]*})\s+from\s+['"]next\/router['"]/g,
|
|
110
|
+
`import { useRouter } from "${sharedPrefix}/RouterContext"`
|
|
111
|
+
);
|
|
112
|
+
return content;
|
|
113
|
+
}
|
|
114
|
+
function rewriteDataFetching(content) {
|
|
115
|
+
const changes = [];
|
|
116
|
+
if (content.includes("getServerSideProps")) {
|
|
117
|
+
content = content.replace(/getServerSideProps/g, "getServerProps");
|
|
118
|
+
changes.push("getServerSideProps \u2192 getServerProps");
|
|
119
|
+
}
|
|
120
|
+
if (content.includes("getStaticProps") && !content.includes("force-static")) {
|
|
121
|
+
content += `
|
|
122
|
+
|
|
123
|
+
// Mark this page for static generation
|
|
124
|
+
export const dynamic = 'force-static';
|
|
125
|
+
`;
|
|
126
|
+
changes.push("Added export const dynamic = 'force-static'");
|
|
127
|
+
}
|
|
128
|
+
content = content.replace(/GetServerSidePropsContext/g, "BlumenContext");
|
|
129
|
+
if (content.includes("BlumenContext") && !content.includes("import")) {
|
|
130
|
+
changes.push("GetServerSidePropsContext \u2192 BlumenContext");
|
|
131
|
+
}
|
|
132
|
+
return { content, changes };
|
|
133
|
+
}
|
|
134
|
+
async function migrate(args = []) {
|
|
135
|
+
banner();
|
|
136
|
+
const dryRun = args.includes("--dry-run");
|
|
137
|
+
const sourceDir = args.find((a) => !a.startsWith("--")) || ".";
|
|
138
|
+
const absDir = path2.resolve(sourceDir);
|
|
139
|
+
log.info(`Analyzing ${c.bold}${absDir}${c.reset} for Next.js project...`);
|
|
140
|
+
log.blank();
|
|
141
|
+
if (!isNextJsProject(absDir)) {
|
|
142
|
+
log.error("No Next.js project detected.");
|
|
143
|
+
log.info("Expected: next.config.js/mjs/ts or pages/ directory");
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
log.success("Next.js project detected!");
|
|
147
|
+
log.blank();
|
|
148
|
+
const result = {
|
|
149
|
+
moved: [],
|
|
150
|
+
rewritten: [],
|
|
151
|
+
warnings: [],
|
|
152
|
+
errors: []
|
|
153
|
+
};
|
|
154
|
+
const pagesDir = findPagesDir(absDir);
|
|
155
|
+
if (!pagesDir) {
|
|
156
|
+
log.error("Could not find pages/ or src/pages/ directory");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
const isAppRouter = pagesDir.endsWith("/app");
|
|
160
|
+
if (isAppRouter) {
|
|
161
|
+
result.warnings.push("Next.js App Router detected. Migration from App Router requires manual review.");
|
|
162
|
+
}
|
|
163
|
+
log.info(`Pages directory: ${c.bold}${path2.relative(absDir, pagesDir)}/${c.reset}`);
|
|
164
|
+
log.blank();
|
|
165
|
+
divider();
|
|
166
|
+
log.blank();
|
|
167
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 1: Analyzing files${c.reset}`);
|
|
168
|
+
log.blank();
|
|
169
|
+
const files = getAllTsxFiles(pagesDir);
|
|
170
|
+
console.log(` Found ${files.length} source file(s)`);
|
|
171
|
+
log.blank();
|
|
172
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 2: Rewriting imports${c.reset}`);
|
|
173
|
+
log.blank();
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const relPath = path2.relative(pagesDir, file);
|
|
176
|
+
const depth = relPath.split("/").length;
|
|
177
|
+
let content = fs2.readFileSync(file, "utf-8");
|
|
178
|
+
let modified = false;
|
|
179
|
+
const fileChanges = [];
|
|
180
|
+
const newContent = rewriteImports(content, depth);
|
|
181
|
+
if (newContent !== content) {
|
|
182
|
+
content = newContent;
|
|
183
|
+
modified = true;
|
|
184
|
+
fileChanges.push("Import rewrites");
|
|
185
|
+
}
|
|
186
|
+
const { content: dfContent, changes } = rewriteDataFetching(content);
|
|
187
|
+
if (changes.length > 0) {
|
|
188
|
+
content = dfContent;
|
|
189
|
+
modified = true;
|
|
190
|
+
fileChanges.push(...changes);
|
|
191
|
+
}
|
|
192
|
+
if (modified) {
|
|
193
|
+
if (!dryRun) {
|
|
194
|
+
fs2.writeFileSync(file, content, "utf-8");
|
|
195
|
+
}
|
|
196
|
+
console.log(` ${c.green}\u2713${c.reset} ${relPath}: ${fileChanges.join(", ")}`);
|
|
197
|
+
result.rewritten.push(relPath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (result.rewritten.length === 0) {
|
|
201
|
+
console.log(` ${c.dim}No import rewrites needed${c.reset}`);
|
|
202
|
+
}
|
|
203
|
+
log.blank();
|
|
204
|
+
console.log(` ${c.bold}${c.cyan}\u25B8 Step 3: File structure mapping${c.reset}`);
|
|
205
|
+
log.blank();
|
|
206
|
+
const mappings = [
|
|
207
|
+
["pages/_app.tsx", "\u2192", "app/shared/_app.tsx"],
|
|
208
|
+
["pages/_document.tsx", "\u2192", "app/shared/_document.tsx"],
|
|
209
|
+
["pages/index.tsx", "\u2192", "app/pages/Home.tsx"],
|
|
210
|
+
["pages/[slug].tsx", "\u2192", "app/pages/[slug].tsx"],
|
|
211
|
+
["pages/api/", "\u2192", "app/api/ (Blumen API routes)"],
|
|
212
|
+
["public/", "\u2192", "static/"],
|
|
213
|
+
["styles/", "\u2192", "app/shared/styles/"]
|
|
214
|
+
];
|
|
215
|
+
for (const [from, arrow, to] of mappings) {
|
|
216
|
+
console.log(` ${c.dim}${from}${c.reset} ${arrow} ${c.bold}${to}${c.reset}`);
|
|
217
|
+
}
|
|
218
|
+
log.blank();
|
|
219
|
+
divider();
|
|
220
|
+
log.blank();
|
|
221
|
+
console.log(` ${c.bold}Migration Summary${c.reset}`);
|
|
222
|
+
log.blank();
|
|
223
|
+
const summaryTable = [
|
|
224
|
+
["Files analyzed", `${files.length}`],
|
|
225
|
+
["Imports rewritten", `${result.rewritten.length}`],
|
|
226
|
+
["Warnings", `${result.warnings.length}`],
|
|
227
|
+
["Mode", dryRun ? "Dry run (no changes)" : "Applied"]
|
|
228
|
+
];
|
|
229
|
+
for (const [label, value] of summaryTable) {
|
|
230
|
+
console.log(` ${c.dim}${label}:${c.reset} ${value}`);
|
|
231
|
+
}
|
|
232
|
+
log.blank();
|
|
233
|
+
if (result.warnings.length > 0) {
|
|
234
|
+
console.log(` ${c.yellow}${c.bold}Warnings:${c.reset}`);
|
|
235
|
+
for (const w of result.warnings) {
|
|
236
|
+
console.log(` ${c.yellow}\u26A0${c.reset} ${w}`);
|
|
237
|
+
}
|
|
238
|
+
log.blank();
|
|
239
|
+
}
|
|
240
|
+
console.log(` ${c.bold}Next Steps${c.reset}`);
|
|
241
|
+
log.blank();
|
|
242
|
+
console.log(` 1. Move your page files to ${c.bold}app/pages/${c.reset}`);
|
|
243
|
+
console.log(` 2. Move ${c.bold}_app.tsx${c.reset} to ${c.bold}app/shared/_app.tsx${c.reset}`);
|
|
244
|
+
console.log(` 3. Move ${c.bold}public/${c.reset} to ${c.bold}static/${c.reset}`);
|
|
245
|
+
console.log(` 4. Replace ${c.bold}next/image${c.reset} usage with standard ${c.bold}<img>${c.reset} or ${c.bold}<BlumenImage>${c.reset}`);
|
|
246
|
+
console.log(` 5. Run ${c.bold}blumen dev${c.reset} to test`);
|
|
247
|
+
log.blank();
|
|
248
|
+
console.log(` ${c.bold}API Comparison${c.reset}`);
|
|
249
|
+
log.blank();
|
|
250
|
+
const comparison = [
|
|
251
|
+
["next/link", "Link from shared/Link", "\u2713 Auto-converted"],
|
|
252
|
+
["next/head", "BlumenHead", "\u2713 Auto-converted"],
|
|
253
|
+
["next/router", "useRouter from RouterContext", "\u2713 Auto-converted"],
|
|
254
|
+
["getServerSideProps", "getServerProps", "\u2713 Auto-converted"],
|
|
255
|
+
["getStaticProps", "getStaticProps", "\u2713 Same API + force-static"],
|
|
256
|
+
["next/image", "<img> or <BlumenImage>", "\u26A0 Manual review"],
|
|
257
|
+
["API Routes (pages/api)", "app/api/ handlers", "\u26A0 Manual migration"],
|
|
258
|
+
["Middleware", "Go middleware", "\u26A0 Manual migration"]
|
|
259
|
+
];
|
|
260
|
+
for (const [next, blumen, status] of comparison) {
|
|
261
|
+
console.log(` ${c.dim}${next.padEnd(25)}${c.reset}\u2192 ${blumen.padEnd(30)} ${status}`);
|
|
262
|
+
}
|
|
263
|
+
log.blank();
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
migrate
|
|
267
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// cli/commands/test.ts
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
|
|
4
|
+
// cli/utils.ts
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
var c = {
|
|
9
|
+
reset: "\x1B[0m",
|
|
10
|
+
bold: "\x1B[1m",
|
|
11
|
+
dim: "\x1B[2m",
|
|
12
|
+
red: "\x1B[31m",
|
|
13
|
+
green: "\x1B[32m",
|
|
14
|
+
yellow: "\x1B[33m",
|
|
15
|
+
blue: "\x1B[34m",
|
|
16
|
+
magenta: "\x1B[35m",
|
|
17
|
+
cyan: "\x1B[36m",
|
|
18
|
+
white: "\x1B[37m",
|
|
19
|
+
gray: "\x1B[90m"
|
|
20
|
+
};
|
|
21
|
+
var log = {
|
|
22
|
+
info: (msg) => console.log(` ${c.magenta}\u25CF${c.reset} ${msg}`),
|
|
23
|
+
success: (msg) => console.log(` ${c.green}\u2713${c.reset} ${msg}`),
|
|
24
|
+
error: (msg) => console.error(` ${c.red}\u2717${c.reset} ${msg}`),
|
|
25
|
+
warn: (msg) => console.log(` ${c.yellow}\u26A0${c.reset} ${msg}`),
|
|
26
|
+
step: (msg) => console.log(` ${c.dim}\u2192${c.reset} ${msg}`),
|
|
27
|
+
blank: () => console.log("")
|
|
28
|
+
};
|
|
29
|
+
function getVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
32
|
+
let dir = path.dirname(thisFile);
|
|
33
|
+
for (let i = 0; i < 5; i++) {
|
|
34
|
+
const pkgFile = path.join(dir, "package.json");
|
|
35
|
+
if (fs.existsSync(pkgFile)) {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf-8"));
|
|
37
|
+
if (pkg.name === "blumenjs" || pkg.name === "go-react-ssr") {
|
|
38
|
+
return pkg.version;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
dir = path.dirname(dir);
|
|
42
|
+
}
|
|
43
|
+
return "0.0.0";
|
|
44
|
+
} catch {
|
|
45
|
+
return "0.0.0";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function banner() {
|
|
49
|
+
const version = getVersion();
|
|
50
|
+
console.log("");
|
|
51
|
+
console.log(
|
|
52
|
+
` ${c.magenta}${c.bold}\u{1F338} Blumen${c.reset} ${c.dim}v${version}${c.reset}`
|
|
53
|
+
);
|
|
54
|
+
console.log(
|
|
55
|
+
` ${c.dim}The React framework powered by Go${c.reset}`
|
|
56
|
+
);
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
function divider() {
|
|
60
|
+
console.log(` ${c.dim}${"\u2500".repeat(48)}${c.reset}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// cli/commands/test.ts
|
|
64
|
+
async function test(args = []) {
|
|
65
|
+
banner();
|
|
66
|
+
const watch = args.includes("--watch");
|
|
67
|
+
const coverage = args.includes("--coverage");
|
|
68
|
+
const ui = args.includes("--ui");
|
|
69
|
+
const pattern = args.filter((a) => !a.startsWith("--")).join(" ");
|
|
70
|
+
let cmd = "npx vitest run";
|
|
71
|
+
if (watch) {
|
|
72
|
+
cmd = "npx vitest";
|
|
73
|
+
log.info("Starting test watcher...");
|
|
74
|
+
} else if (ui) {
|
|
75
|
+
cmd = "npx vitest --ui";
|
|
76
|
+
log.info("Opening Vitest UI...");
|
|
77
|
+
} else if (coverage) {
|
|
78
|
+
cmd = "npx vitest run --coverage";
|
|
79
|
+
log.info("Running tests with coverage...");
|
|
80
|
+
} else {
|
|
81
|
+
log.info("Running tests...");
|
|
82
|
+
}
|
|
83
|
+
if (pattern) {
|
|
84
|
+
cmd += ` ${pattern}`;
|
|
85
|
+
}
|
|
86
|
+
log.blank();
|
|
87
|
+
divider();
|
|
88
|
+
log.blank();
|
|
89
|
+
try {
|
|
90
|
+
execSync(cmd, {
|
|
91
|
+
stdio: "inherit",
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
env: {
|
|
94
|
+
...process.env,
|
|
95
|
+
NODE_ENV: "test"
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
log.blank();
|
|
99
|
+
divider();
|
|
100
|
+
log.blank();
|
|
101
|
+
log.success("All tests passed! \u2728");
|
|
102
|
+
log.blank();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.blank();
|
|
105
|
+
divider();
|
|
106
|
+
log.blank();
|
|
107
|
+
if (err.status) {
|
|
108
|
+
log.error(`Tests failed with exit code ${err.status}`);
|
|
109
|
+
process.exit(err.status);
|
|
110
|
+
} else {
|
|
111
|
+
log.error("Test runner encountered an error");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export {
|
|
117
|
+
test
|
|
118
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"log"
|
|
8
|
+
"net/http"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// ─── Server Actions Handler ────────────────────────────────────
|
|
14
|
+
// Handles POST /_blumen/action requests from client components.
|
|
15
|
+
// Validates CSRF tokens, then forwards to the Node SSR server
|
|
16
|
+
// for execution.
|
|
17
|
+
|
|
18
|
+
// actionRequest is the JSON body from the client.
|
|
19
|
+
type actionRequest struct {
|
|
20
|
+
Action string `json:"action"`
|
|
21
|
+
Input interface{} `json:"input"`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// actionResponse is the response from the Node SSR server.
|
|
25
|
+
type actionResponse struct {
|
|
26
|
+
Success bool `json:"success"`
|
|
27
|
+
Data interface{} `json:"data,omitempty"`
|
|
28
|
+
Error string `json:"error,omitempty"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ActionHandler creates the HTTP handler for server actions.
|
|
32
|
+
func ActionHandler() http.HandlerFunc {
|
|
33
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
34
|
+
// Only POST is allowed
|
|
35
|
+
if r.Method != http.MethodPost {
|
|
36
|
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── CSRF Validation (double-submit cookie pattern) ────
|
|
41
|
+
// The client sends a token in the X-CSRF-Token header
|
|
42
|
+
// and also sets it as a _blumen_csrf cookie.
|
|
43
|
+
// We validate that they match.
|
|
44
|
+
headerToken := r.Header.Get("X-CSRF-Token")
|
|
45
|
+
if headerToken == "" {
|
|
46
|
+
w.Header().Set("Content-Type", "application/json")
|
|
47
|
+
w.WriteHeader(http.StatusForbidden)
|
|
48
|
+
json.NewEncoder(w).Encode(actionResponse{
|
|
49
|
+
Success: false,
|
|
50
|
+
Error: "Missing CSRF token",
|
|
51
|
+
})
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Get CSRF cookie
|
|
56
|
+
cookieToken := ""
|
|
57
|
+
if cookie, err := r.Cookie("_blumen_csrf"); err == nil {
|
|
58
|
+
cookieToken = cookie.Value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Validate: header token must match cookie token
|
|
62
|
+
if cookieToken == "" || headerToken != cookieToken {
|
|
63
|
+
w.Header().Set("Content-Type", "application/json")
|
|
64
|
+
w.WriteHeader(http.StatusForbidden)
|
|
65
|
+
json.NewEncoder(w).Encode(actionResponse{
|
|
66
|
+
Success: false,
|
|
67
|
+
Error: "Invalid CSRF token",
|
|
68
|
+
})
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Parse request body ────────────────────────────────
|
|
73
|
+
body, err := io.ReadAll(r.Body)
|
|
74
|
+
if err != nil {
|
|
75
|
+
w.Header().Set("Content-Type", "application/json")
|
|
76
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
77
|
+
json.NewEncoder(w).Encode(actionResponse{
|
|
78
|
+
Success: false,
|
|
79
|
+
Error: "Failed to read request body",
|
|
80
|
+
})
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
defer r.Body.Close()
|
|
84
|
+
|
|
85
|
+
var actionReq actionRequest
|
|
86
|
+
if err := json.Unmarshal(body, &actionReq); err != nil {
|
|
87
|
+
w.Header().Set("Content-Type", "application/json")
|
|
88
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
89
|
+
json.NewEncoder(w).Encode(actionResponse{
|
|
90
|
+
Success: false,
|
|
91
|
+
Error: "Invalid request body",
|
|
92
|
+
})
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if actionReq.Action == "" {
|
|
97
|
+
w.Header().Set("Content-Type", "application/json")
|
|
98
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
99
|
+
json.NewEncoder(w).Encode(actionResponse{
|
|
100
|
+
Success: false,
|
|
101
|
+
Error: "Action name is required",
|
|
102
|
+
})
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Forward to Node SSR server ────────────────────────
|
|
107
|
+
nodeURL := fmt.Sprintf("http://localhost:4000/action")
|
|
108
|
+
|
|
109
|
+
nodeBody, _ := json.Marshal(map[string]interface{}{
|
|
110
|
+
"action": actionReq.Action,
|
|
111
|
+
"input": actionReq.Input,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
nodeReq, err := http.NewRequest("POST", nodeURL, strings.NewReader(string(nodeBody)))
|
|
115
|
+
if err != nil {
|
|
116
|
+
log.Printf("Action proxy error: %v", err)
|
|
117
|
+
w.Header().Set("Content-Type", "application/json")
|
|
118
|
+
w.WriteHeader(http.StatusInternalServerError)
|
|
119
|
+
json.NewEncoder(w).Encode(actionResponse{
|
|
120
|
+
Success: false,
|
|
121
|
+
Error: "Failed to create action request",
|
|
122
|
+
})
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
nodeReq.Header.Set("Content-Type", "application/json")
|
|
126
|
+
|
|
127
|
+
client := &http.Client{Timeout: 30 * time.Second}
|
|
128
|
+
resp, err := client.Do(nodeReq)
|
|
129
|
+
if err != nil {
|
|
130
|
+
log.Printf("Action execution error: %v", err)
|
|
131
|
+
w.Header().Set("Content-Type", "application/json")
|
|
132
|
+
w.WriteHeader(http.StatusServiceUnavailable)
|
|
133
|
+
json.NewEncoder(w).Encode(actionResponse{
|
|
134
|
+
Success: false,
|
|
135
|
+
Error: "Action server unavailable",
|
|
136
|
+
})
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
defer resp.Body.Close()
|
|
140
|
+
|
|
141
|
+
// Forward the response from Node
|
|
142
|
+
respBody, _ := io.ReadAll(resp.Body)
|
|
143
|
+
w.Header().Set("Content-Type", "application/json")
|
|
144
|
+
w.WriteHeader(resp.StatusCode)
|
|
145
|
+
w.Write(respBody)
|
|
146
|
+
}
|
|
147
|
+
}
|