threadforge 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/forge.js +1050 -0
- package/bin/host-commands.js +344 -0
- package/bin/platform-commands.js +570 -0
- package/package.json +71 -0
- package/shared/auth.js +475 -0
- package/src/core/DirectMessageBus.js +364 -0
- package/src/core/EndpointResolver.js +247 -0
- package/src/core/ForgeContext.js +2227 -0
- package/src/core/ForgeHost.js +122 -0
- package/src/core/ForgePlatform.js +145 -0
- package/src/core/Ingress.js +768 -0
- package/src/core/Interceptors.js +420 -0
- package/src/core/MessageBus.js +310 -0
- package/src/core/Prometheus.js +305 -0
- package/src/core/RequestContext.js +413 -0
- package/src/core/RoutingStrategy.js +316 -0
- package/src/core/Supervisor.js +1306 -0
- package/src/core/ThreadAllocator.js +196 -0
- package/src/core/WorkerChannelManager.js +879 -0
- package/src/core/config.js +624 -0
- package/src/core/host-config.js +311 -0
- package/src/core/network-utils.js +166 -0
- package/src/core/platform-config.js +308 -0
- package/src/decorators/ServiceProxy.js +899 -0
- package/src/decorators/index.js +571 -0
- package/src/deploy/NginxGenerator.js +865 -0
- package/src/deploy/PlatformManifestGenerator.js +96 -0
- package/src/deploy/RouteManifestGenerator.js +112 -0
- package/src/deploy/index.js +984 -0
- package/src/frontend/FrontendDevLifecycle.js +65 -0
- package/src/frontend/FrontendPluginOrchestrator.js +187 -0
- package/src/frontend/SiteResolver.js +63 -0
- package/src/frontend/StaticMountRegistry.js +90 -0
- package/src/frontend/index.js +5 -0
- package/src/frontend/plugins/index.js +2 -0
- package/src/frontend/plugins/viteFrontend.js +79 -0
- package/src/frontend/types.js +35 -0
- package/src/index.js +56 -0
- package/src/internals.js +31 -0
- package/src/plugins/PluginManager.js +537 -0
- package/src/plugins/ScopedPostgres.js +192 -0
- package/src/plugins/ScopedRedis.js +142 -0
- package/src/plugins/index.js +1729 -0
- package/src/registry/ServiceRegistry.js +796 -0
- package/src/scaling/ScaleAdvisor.js +442 -0
- package/src/services/Service.js +195 -0
- package/src/services/worker-bootstrap.js +676 -0
- package/src/templates/auth-service.js +65 -0
- package/src/templates/identity-service.js +75 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ForgePlatform CLI Commands
|
|
3
|
+
*
|
|
4
|
+
* Subcommands for `forge platform`:
|
|
5
|
+
* forge platform init Scaffold forge.platform.js + shared services + apps/
|
|
6
|
+
* forge platform start Start the platform
|
|
7
|
+
* forge platform add <name> Scaffold a new app
|
|
8
|
+
* forge platform remove <id> Print removal instructions
|
|
9
|
+
* forge platform status Show per-app status table
|
|
10
|
+
* forge platform generate Generate nginx + platform.yaml + route manifests
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
const PLATFORM_CONFIG_NAMES = ["forge.platform.js", "forge.platform.mjs"];
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const LOCAL_PKG_PATH = path.resolve(__dirname, "..", "package.json");
|
|
20
|
+
|
|
21
|
+
async function findPlatformConfig() {
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
for (const name of PLATFORM_CONFIG_NAMES) {
|
|
24
|
+
const fullPath = path.join(cwd, name);
|
|
25
|
+
try {
|
|
26
|
+
await fs.promises.access(fullPath);
|
|
27
|
+
return fullPath;
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compute the import path for threadforge from a given source directory.
|
|
35
|
+
* Returns a relative path when running from a local clone, or "threadforge"
|
|
36
|
+
* when installed as a dependency.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} fromDir - The directory the import will appear in
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function computeImportPath(fromDir) {
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(LOCAL_PKG_PATH)) {
|
|
44
|
+
const pkg = JSON.parse(fs.readFileSync(LOCAL_PKG_PATH, "utf8"));
|
|
45
|
+
if (pkg.name === "threadforge") {
|
|
46
|
+
const indexAbsolute = fs.realpathSync(path.resolve(__dirname, "..", "src", "index.js"));
|
|
47
|
+
const realFrom = fs.realpathSync(fromDir);
|
|
48
|
+
let rel = path.relative(realFrom, indexAbsolute);
|
|
49
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
50
|
+
return rel;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
return "threadforge";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadPlatformConfig() {
|
|
58
|
+
const configPath = await findPlatformConfig();
|
|
59
|
+
if (!configPath) {
|
|
60
|
+
console.error(" Error: No forge.platform.js found in current directory.");
|
|
61
|
+
console.error(" Run `forge platform init` to create one.");
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
65
|
+
const mod = await import(configUrl);
|
|
66
|
+
return mod.default ?? mod;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadFrontendBuildManifest(cwd) {
|
|
70
|
+
const manifestPath = path.join(cwd, ".threadforge", "frontend-manifest.json");
|
|
71
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
72
|
+
try {
|
|
73
|
+
const json = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
74
|
+
return { path: manifestPath, data: json };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.warn(` Warning: Could not parse ${manifestPath}: ${err.message}`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── forge platform init ──────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async function cmdPlatformInit() {
|
|
84
|
+
const cwd = process.cwd();
|
|
85
|
+
const configPath = path.join(cwd, "forge.platform.js");
|
|
86
|
+
|
|
87
|
+
if (fs.existsSync(configPath)) {
|
|
88
|
+
console.error(" forge.platform.js already exists in this directory.");
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const importPath = computeImportPath(cwd);
|
|
93
|
+
const serviceImportPath = computeImportPath(path.join(cwd, "shared"));
|
|
94
|
+
|
|
95
|
+
// Create directories
|
|
96
|
+
for (const dir of ["shared", "apps"]) {
|
|
97
|
+
fs.mkdirSync(path.join(cwd, dir), { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Write platform config
|
|
101
|
+
fs.writeFileSync(
|
|
102
|
+
configPath,
|
|
103
|
+
`import { definePlatform } from '${importPath}';
|
|
104
|
+
|
|
105
|
+
export default definePlatform({
|
|
106
|
+
platform: {
|
|
107
|
+
globalAuth: true,
|
|
108
|
+
sessionSharing: true,
|
|
109
|
+
|
|
110
|
+
apps: {
|
|
111
|
+
// Add apps here:
|
|
112
|
+
// myapp: {
|
|
113
|
+
// domains: ['myapp.example.com'],
|
|
114
|
+
// services: ['myapp-api'],
|
|
115
|
+
// schema: 'myapp',
|
|
116
|
+
// },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// Plugins (shared across all apps)
|
|
121
|
+
// plugins: [postgres(), redis()],
|
|
122
|
+
|
|
123
|
+
// Shared services (run once, serve all apps)
|
|
124
|
+
identity: {
|
|
125
|
+
entry: './shared/identity.js',
|
|
126
|
+
type: 'edge',
|
|
127
|
+
port: 3001,
|
|
128
|
+
},
|
|
129
|
+
auth: {
|
|
130
|
+
entry: './shared/auth.js',
|
|
131
|
+
type: 'edge',
|
|
132
|
+
port: 3002,
|
|
133
|
+
prefix: '/auth',
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// Per-app services go here:
|
|
137
|
+
// 'myapp-api': {
|
|
138
|
+
// entry: './apps/myapp/api.js',
|
|
139
|
+
// type: 'edge',
|
|
140
|
+
// port: 4001,
|
|
141
|
+
// connects: ['auth'],
|
|
142
|
+
// },
|
|
143
|
+
});
|
|
144
|
+
`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Write identity service stub
|
|
148
|
+
const identityPath = path.join(cwd, "shared", "identity.js");
|
|
149
|
+
if (!fs.existsSync(identityPath)) {
|
|
150
|
+
fs.writeFileSync(
|
|
151
|
+
identityPath,
|
|
152
|
+
`import { Service } from '${serviceImportPath}';
|
|
153
|
+
|
|
154
|
+
export default class IdentityService extends Service {
|
|
155
|
+
static contract = {
|
|
156
|
+
expose: ['getUser', 'getUserByEmail', 'createUser', 'listMembers'],
|
|
157
|
+
routes: [
|
|
158
|
+
{ method: 'POST', path: '/login', handler: 'login' },
|
|
159
|
+
{ method: 'POST', path: '/register', handler: 'register' },
|
|
160
|
+
{ method: 'GET', path: '/users/:id', handler: 'getUserRoute' },
|
|
161
|
+
{ method: 'GET', path: '/users', handler: 'listUsersRoute' },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
async onStart(ctx) {
|
|
166
|
+
ctx.logger.info('Identity service ready');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async login(_body) { return { error: 'Not implemented' }; }
|
|
170
|
+
async register(_body) { return { error: 'Not implemented' }; }
|
|
171
|
+
async getUserRoute(_body, params) { return this.getUser(params.id); }
|
|
172
|
+
async listUsersRoute() { return { error: 'Not implemented' }; }
|
|
173
|
+
async getUser(_userId) { return { error: 'Not implemented' }; }
|
|
174
|
+
async getUserByEmail(_email) { return { error: 'Not implemented' }; }
|
|
175
|
+
async createUser(_data) { return { error: 'Not implemented' }; }
|
|
176
|
+
async listMembers(_appId) { return { error: 'Not implemented' }; }
|
|
177
|
+
}
|
|
178
|
+
`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Write auth service stub
|
|
183
|
+
const authPath = path.join(cwd, "shared", "auth.js");
|
|
184
|
+
if (!fs.existsSync(authPath)) {
|
|
185
|
+
fs.writeFileSync(
|
|
186
|
+
authPath,
|
|
187
|
+
`import { Service } from '${serviceImportPath}';
|
|
188
|
+
|
|
189
|
+
export default class AuthService extends Service {
|
|
190
|
+
static contract = {
|
|
191
|
+
expose: ['validateToken', 'issueToken', 'revokeToken'],
|
|
192
|
+
routes: [
|
|
193
|
+
{ method: 'POST', path: '/token', handler: 'issueTokenRoute' },
|
|
194
|
+
{ method: 'POST', path: '/token/validate', handler: 'validateTokenRoute' },
|
|
195
|
+
{ method: 'POST', path: '/token/revoke', handler: 'revokeTokenRoute' },
|
|
196
|
+
{ method: 'POST', path: '/token/refresh', handler: 'refreshTokenRoute' },
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
async onStart(ctx) {
|
|
201
|
+
ctx.logger.info('Auth service ready');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async issueTokenRoute(_body) { return { error: 'Not implemented' }; }
|
|
205
|
+
async validateTokenRoute(_body) { return { error: 'Not implemented' }; }
|
|
206
|
+
async revokeTokenRoute(_body) { return { error: 'Not implemented' }; }
|
|
207
|
+
async refreshTokenRoute(_body) { return { error: 'Not implemented' }; }
|
|
208
|
+
async validateToken(_token) { return { error: 'Not implemented' }; }
|
|
209
|
+
async issueToken(_payload) { return { error: 'Not implemented' }; }
|
|
210
|
+
async revokeToken(_token) { return { error: 'Not implemented' }; }
|
|
211
|
+
}
|
|
212
|
+
`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(`
|
|
217
|
+
⚡ ForgePlatform initialized!
|
|
218
|
+
|
|
219
|
+
Created:
|
|
220
|
+
forge.platform.js - Platform configuration
|
|
221
|
+
shared/identity.js - Identity service (stub)
|
|
222
|
+
shared/auth.js - Auth service (stub)
|
|
223
|
+
apps/ - App directory
|
|
224
|
+
|
|
225
|
+
Next steps:
|
|
226
|
+
1. Edit forge.platform.js to configure your apps
|
|
227
|
+
2. forge platform add myapp
|
|
228
|
+
3. forge platform start
|
|
229
|
+
`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── forge platform start ─────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
async function cmdPlatformStart() {
|
|
235
|
+
const platformConfig = await loadPlatformConfig();
|
|
236
|
+
const { ForgePlatform } = await import("../src/core/ForgePlatform.js");
|
|
237
|
+
|
|
238
|
+
const platform = new ForgePlatform(platformConfig);
|
|
239
|
+
await platform.start();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── forge platform add <name> ────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
async function cmdPlatformAdd(args) {
|
|
245
|
+
const appName = args[0];
|
|
246
|
+
if (!appName) {
|
|
247
|
+
console.error(" Usage: forge platform add <app-name>");
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(appName)) {
|
|
252
|
+
console.error(` Invalid app name "${appName}": must start with a lowercase letter and contain only lowercase letters, digits, hyphens, and underscores.`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const cwd = process.cwd();
|
|
257
|
+
const appDir = path.join(cwd, "apps", appName);
|
|
258
|
+
|
|
259
|
+
if (fs.existsSync(appDir)) {
|
|
260
|
+
console.error(` App directory already exists: apps/${appName}/`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const serviceImportPath = computeImportPath(path.join(cwd, "apps", appName));
|
|
265
|
+
|
|
266
|
+
// Create app directory + migrations
|
|
267
|
+
fs.mkdirSync(path.join(appDir, "migrations"), { recursive: true });
|
|
268
|
+
|
|
269
|
+
// Write service file
|
|
270
|
+
fs.writeFileSync(
|
|
271
|
+
path.join(appDir, "api.js"),
|
|
272
|
+
`import { Service } from '${serviceImportPath}';
|
|
273
|
+
|
|
274
|
+
export default class ${capitalize(appName)}ApiService extends Service {
|
|
275
|
+
static contract = {
|
|
276
|
+
expose: ['getStatus'],
|
|
277
|
+
routes: [
|
|
278
|
+
{ method: 'GET', path: '/health', handler: 'healthCheck' },
|
|
279
|
+
{ method: 'GET', path: '/hello/:name', handler: 'hello' },
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
async onStart(ctx) {
|
|
284
|
+
ctx.logger.info('${appName} API service ready');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async healthCheck() {
|
|
288
|
+
return { status: 'ok', app: '${appName}', worker: this.ctx.workerId };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async hello(_body, params) {
|
|
292
|
+
return { message: \`Hello from ${appName}, \${params.name}!\` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async getStatus() {
|
|
296
|
+
return { app: '${appName}', status: 'running' };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
`,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Write migration template
|
|
303
|
+
const safeName = appName.replace(/"/g, '""');
|
|
304
|
+
fs.writeFileSync(
|
|
305
|
+
path.join(appDir, "migrations", "001_init.sql"),
|
|
306
|
+
`-- Migration: ${appName} initial schema
|
|
307
|
+
-- This runs in the app's isolated schema (SET search_path = ${appName})
|
|
308
|
+
|
|
309
|
+
CREATE TABLE IF NOT EXISTS "${safeName}_items" (
|
|
310
|
+
id SERIAL PRIMARY KEY,
|
|
311
|
+
name TEXT NOT NULL,
|
|
312
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
313
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
CREATE INDEX IF NOT EXISTS "idx_${safeName}_items_created"
|
|
317
|
+
ON "${safeName}_items" (created_at);
|
|
318
|
+
`,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
console.log(`
|
|
322
|
+
⚡ App "${appName}" scaffolded!
|
|
323
|
+
|
|
324
|
+
Created:
|
|
325
|
+
apps/${appName}/api.js - API service
|
|
326
|
+
apps/${appName}/migrations/001_init.sql - Initial migration
|
|
327
|
+
|
|
328
|
+
Add this to your forge.platform.js:
|
|
329
|
+
|
|
330
|
+
// In platform.apps:
|
|
331
|
+
${appName}: {
|
|
332
|
+
domains: ['${appName}.example.com'],
|
|
333
|
+
services: ['${appName}-api'],
|
|
334
|
+
schema: '${appName}',
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
// As a top-level service:
|
|
338
|
+
'${appName}-api': {
|
|
339
|
+
entry: './apps/${appName}/api.js',
|
|
340
|
+
type: 'edge',
|
|
341
|
+
port: 4001,
|
|
342
|
+
connects: ['auth'],
|
|
343
|
+
},
|
|
344
|
+
`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function capitalize(str) {
|
|
348
|
+
return str.charAt(0).toUpperCase() + str.slice(1).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── forge platform remove <id> ───────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
async function cmdPlatformRemove(args) {
|
|
354
|
+
const appId = args[0];
|
|
355
|
+
if (!appId) {
|
|
356
|
+
console.error(" Usage: forge platform remove <app-id>");
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const dropData = args.includes("--drop-data");
|
|
361
|
+
|
|
362
|
+
console.log(`
|
|
363
|
+
To remove app "${appId}":
|
|
364
|
+
1. Remove its entry from platform.apps in forge.platform.js
|
|
365
|
+
2. Remove its service definitions from forge.platform.js
|
|
366
|
+
3. Restart: forge platform start
|
|
367
|
+
${dropData ? `4. To drop data, manually run this SQL:\n DROP SCHEMA "${appId}" CASCADE;` : `4. Data preserved in schema "${appId}"`}
|
|
368
|
+
5. Optionally delete: rm -rf apps/${appId}/
|
|
369
|
+
`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── forge platform status ────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
async function cmdPlatformStatus() {
|
|
375
|
+
const metricsPort = parseInt(process.env.FORGE_METRICS_PORT || '9090', 10);
|
|
376
|
+
try {
|
|
377
|
+
const res = await fetch(`http://localhost:${metricsPort}/status`);
|
|
378
|
+
const data = await res.json();
|
|
379
|
+
|
|
380
|
+
console.log("");
|
|
381
|
+
console.log(" ForgePlatform Status");
|
|
382
|
+
console.log(" ──────────────────────────────────────");
|
|
383
|
+
|
|
384
|
+
if (data.projects) {
|
|
385
|
+
console.log("");
|
|
386
|
+
console.log(" ┌──────────────────┬─────────────────────────┬──────────┬─────────┬────────────────┐");
|
|
387
|
+
console.log(" │ App │ Domain │ Services │ Workers │ Schema │");
|
|
388
|
+
console.log(" ├──────────────────┼─────────────────────────┼──────────┼─────────┼────────────────┤");
|
|
389
|
+
|
|
390
|
+
for (const [id, proj] of Object.entries(data.projects)) {
|
|
391
|
+
const name = id.padEnd(16);
|
|
392
|
+
const domain = (proj.domain ?? "—").substring(0, 23).padEnd(23);
|
|
393
|
+
const services = String(proj.services).padEnd(8);
|
|
394
|
+
const workers = String(proj.workers).padEnd(7);
|
|
395
|
+
const schema = (proj.schema ?? "—").substring(0, 14).padEnd(14);
|
|
396
|
+
console.log(` │ ${name} │ ${domain} │ ${services} │ ${workers} │ ${schema} │`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log(" └──────────────────┴─────────────────────────┴──────────┴─────────┴────────────────┘");
|
|
400
|
+
} else {
|
|
401
|
+
console.log(" Not running in platform mode.");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
console.log(` Uptime: ${Math.floor(data.uptime)}s`);
|
|
405
|
+
console.log("");
|
|
406
|
+
} catch {
|
|
407
|
+
console.error(" Could not connect to ForgePlatform runtime.");
|
|
408
|
+
console.error(" Is it running? (forge platform start)");
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── forge platform generate ──────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
async function cmdPlatformGenerate() {
|
|
416
|
+
const platformConfig = await loadPlatformConfig();
|
|
417
|
+
const { resolveAppDomains, transformToHostConfig } = await import("../src/core/platform-config.js");
|
|
418
|
+
const { resolveHostConfig } = await import("../src/core/host-config.js");
|
|
419
|
+
const { generatePlatformNginxConfig } = await import("../src/deploy/NginxGenerator.js");
|
|
420
|
+
const { generatePlatformManifest } = await import("../src/deploy/PlatformManifestGenerator.js");
|
|
421
|
+
|
|
422
|
+
const raw = platformConfig._isPlatformConfig ? platformConfig._raw : platformConfig;
|
|
423
|
+
const hostConfig = transformToHostConfig(raw);
|
|
424
|
+
const resolved = await resolveHostConfig(hostConfig);
|
|
425
|
+
|
|
426
|
+
const cwd = process.cwd();
|
|
427
|
+
const routesDir = path.join(cwd, "routes");
|
|
428
|
+
const deployDir = path.join(cwd, "deploy");
|
|
429
|
+
fs.mkdirSync(routesDir, { recursive: true });
|
|
430
|
+
fs.mkdirSync(deployDir, { recursive: true });
|
|
431
|
+
const frontendManifest = loadFrontendBuildManifest(cwd);
|
|
432
|
+
|
|
433
|
+
/** @type {Record<string, string>} */
|
|
434
|
+
const builtOutDirBySite = {};
|
|
435
|
+
if (frontendManifest?.data?.sites && Array.isArray(frontendManifest.data.sites)) {
|
|
436
|
+
for (const site of frontendManifest.data.sites) {
|
|
437
|
+
if (site?.siteId && site?.outDir) {
|
|
438
|
+
builtOutDirBySite[site.siteId] = site.outDir;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** @type {Record<string, any[]>} */
|
|
444
|
+
const mountsFromManifest = {};
|
|
445
|
+
if (frontendManifest?.data?.mounts && typeof frontendManifest.data.mounts === "object") {
|
|
446
|
+
for (const [siteId, mounts] of Object.entries(frontendManifest.data.mounts)) {
|
|
447
|
+
if (Array.isArray(mounts)) mountsFromManifest[siteId] = mounts;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const apps = {};
|
|
452
|
+
for (const [appId, app] of Object.entries(raw.platform.apps)) {
|
|
453
|
+
const site = resolved.sites?.[appId];
|
|
454
|
+
const frontend = site?.frontend ? { ...site.frontend } : (app.frontend ? { ...app.frontend } : null);
|
|
455
|
+
if (frontend && builtOutDirBySite[appId]) {
|
|
456
|
+
frontend.outDir = builtOutDirBySite[appId];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const staticMounts = mountsFromManifest[appId]
|
|
460
|
+
? mountsFromManifest[appId]
|
|
461
|
+
: (() => {
|
|
462
|
+
if (frontend) {
|
|
463
|
+
return [{
|
|
464
|
+
siteId: appId,
|
|
465
|
+
domains: site?.domains ?? resolveAppDomains(app),
|
|
466
|
+
basePath: frontend.basePath ?? "/",
|
|
467
|
+
dir: frontend.outDir,
|
|
468
|
+
spaFallback: frontend.spaFallback ?? true,
|
|
469
|
+
cachePolicy: "short",
|
|
470
|
+
}];
|
|
471
|
+
}
|
|
472
|
+
const legacyStatic = site?.staticDir ?? app.static ?? null;
|
|
473
|
+
if (!legacyStatic) return [];
|
|
474
|
+
return [{
|
|
475
|
+
siteId: appId,
|
|
476
|
+
domains: site?.domains ?? resolveAppDomains(app),
|
|
477
|
+
basePath: "/static",
|
|
478
|
+
dir: legacyStatic,
|
|
479
|
+
spaFallback: false,
|
|
480
|
+
cachePolicy: "short",
|
|
481
|
+
}];
|
|
482
|
+
})();
|
|
483
|
+
|
|
484
|
+
apps[appId] = {
|
|
485
|
+
domains: resolveAppDomains(app),
|
|
486
|
+
ssl: app.ssl ?? null,
|
|
487
|
+
static: site?.staticDir ?? app.static ?? null,
|
|
488
|
+
frontend,
|
|
489
|
+
staticMounts,
|
|
490
|
+
services: app.services ?? [],
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Generate nginx config
|
|
495
|
+
const nginxConfig = generatePlatformNginxConfig({
|
|
496
|
+
apps,
|
|
497
|
+
services: resolved.services,
|
|
498
|
+
hostMeta: resolved.hostMeta,
|
|
499
|
+
defaultSsl: raw.platform.ssl ?? null,
|
|
500
|
+
maxBodySize: raw.platform.maxBodySize ?? 10,
|
|
501
|
+
});
|
|
502
|
+
fs.writeFileSync(path.join(deployDir, "nginx.conf"), nginxConfig);
|
|
503
|
+
|
|
504
|
+
// Generate platform.yaml
|
|
505
|
+
const manifestWithSites = generatePlatformManifest(raw.platform, resolved.hostMeta, resolved.sites);
|
|
506
|
+
fs.writeFileSync(path.join(routesDir, "platform.yaml"), manifestWithSites);
|
|
507
|
+
|
|
508
|
+
// Generate per-service route manifests
|
|
509
|
+
let manifestCount = 0;
|
|
510
|
+
for (const [name, svc] of Object.entries(resolved.services)) {
|
|
511
|
+
if (svc.type !== "edge") continue;
|
|
512
|
+
const safeName = name.replace(/:/g, "_");
|
|
513
|
+
let yaml = `# Auto-generated for ForgePlatform\n`;
|
|
514
|
+
yaml += `service: ${name}\n`;
|
|
515
|
+
yaml += `prefix: ${svc.prefix ?? "/"}\n`;
|
|
516
|
+
if (svc._projectId) yaml += `project_id: ${svc._projectId}\n`;
|
|
517
|
+
const domain = svc._projectId ? resolved.hostMeta[svc._projectId]?.domain : null;
|
|
518
|
+
if (domain) yaml += `host: ${domain}\n`;
|
|
519
|
+
yaml += `\nroutes:\n`;
|
|
520
|
+
yaml += ` - method: GET\n`;
|
|
521
|
+
yaml += ` path: ""\n`;
|
|
522
|
+
yaml += ` handler: index\n`;
|
|
523
|
+
fs.writeFileSync(path.join(routesDir, `${safeName}.yaml`), yaml);
|
|
524
|
+
manifestCount++;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
console.log(`
|
|
528
|
+
ForgePlatform artifacts generated:
|
|
529
|
+
|
|
530
|
+
deploy/nginx.conf Platform nginx config
|
|
531
|
+
routes/platform.yaml Platform manifest for ForgeProxy
|
|
532
|
+
routes/ ${manifestCount} service route manifest(s)
|
|
533
|
+
${frontendManifest ? `.threadforge/frontend-manifest.json Frontend build manifest (source)` : "(no frontend manifest found)"}
|
|
534
|
+
|
|
535
|
+
ForgeProxy reads ./routes/*.yaml automatically.
|
|
536
|
+
`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Main dispatch ────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
export async function cmdPlatform(args) {
|
|
542
|
+
const subcommand = args[0];
|
|
543
|
+
const subArgs = args.slice(1);
|
|
544
|
+
|
|
545
|
+
switch (subcommand) {
|
|
546
|
+
case "init":
|
|
547
|
+
return cmdPlatformInit();
|
|
548
|
+
case "start":
|
|
549
|
+
return cmdPlatformStart();
|
|
550
|
+
case "add":
|
|
551
|
+
return cmdPlatformAdd(subArgs);
|
|
552
|
+
case "remove":
|
|
553
|
+
return cmdPlatformRemove(subArgs);
|
|
554
|
+
case "status":
|
|
555
|
+
return cmdPlatformStatus();
|
|
556
|
+
case "generate":
|
|
557
|
+
return cmdPlatformGenerate();
|
|
558
|
+
default:
|
|
559
|
+
console.log(`
|
|
560
|
+
ForgePlatform Commands:
|
|
561
|
+
|
|
562
|
+
forge platform init Scaffold a platform project
|
|
563
|
+
forge platform start Start the platform
|
|
564
|
+
forge platform add <name> Scaffold a new app
|
|
565
|
+
forge platform remove <id> Remove an app
|
|
566
|
+
forge platform status Show per-app status
|
|
567
|
+
forge platform generate Generate nginx + route manifests
|
|
568
|
+
`);
|
|
569
|
+
}
|
|
570
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "threadforge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-threaded Node.js service runtime framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"forge": "./bin/forge.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./service": "./src/services/Service.js",
|
|
13
|
+
"./config": "./src/core/config.js",
|
|
14
|
+
"./decorators": "./src/decorators/index.js",
|
|
15
|
+
"./plugins": "./src/plugins/index.js",
|
|
16
|
+
"./plugins/redis": "./src/plugins/redis.js",
|
|
17
|
+
"./plugins/postgres": "./src/plugins/postgres.js",
|
|
18
|
+
"./ingress": "./src/core/Ingress.js",
|
|
19
|
+
"./frontend": "./src/frontend/index.js",
|
|
20
|
+
"./frontend/plugins": "./src/frontend/plugins/index.js",
|
|
21
|
+
"./internals": "./src/internals.js"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "node bin/forge.js dev",
|
|
25
|
+
"start": "node bin/forge.js start",
|
|
26
|
+
"test": "node --test tests/*.test.js",
|
|
27
|
+
"test:watch": "node --test --watch tests/*.test.js",
|
|
28
|
+
"test:coverage": "node --experimental-test-coverage --test tests/*.test.js",
|
|
29
|
+
"lint": "biome check ."
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"microservices",
|
|
33
|
+
"multi-threaded",
|
|
34
|
+
"cluster",
|
|
35
|
+
"multi-process",
|
|
36
|
+
"ipc",
|
|
37
|
+
"service-mesh",
|
|
38
|
+
"service-runtime"
|
|
39
|
+
],
|
|
40
|
+
"author": "ThreadForge Contributors",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/ChrisBland/threadforge.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/ChrisBland/threadforge#readme",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/ChrisBland/threadforge/issues"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"src/",
|
|
52
|
+
"bin/",
|
|
53
|
+
"shared/",
|
|
54
|
+
"README.md",
|
|
55
|
+
"LICENSE"
|
|
56
|
+
],
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20.0.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"ioredis": "^5.3.0",
|
|
62
|
+
"pg": "^8.11.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"ioredis": { "optional": true },
|
|
66
|
+
"pg": { "optional": true }
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@biomejs/biome": "^2.3.15"
|
|
70
|
+
}
|
|
71
|
+
}
|