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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/bin/forge.js +1050 -0
  4. package/bin/host-commands.js +344 -0
  5. package/bin/platform-commands.js +570 -0
  6. package/package.json +71 -0
  7. package/shared/auth.js +475 -0
  8. package/src/core/DirectMessageBus.js +364 -0
  9. package/src/core/EndpointResolver.js +247 -0
  10. package/src/core/ForgeContext.js +2227 -0
  11. package/src/core/ForgeHost.js +122 -0
  12. package/src/core/ForgePlatform.js +145 -0
  13. package/src/core/Ingress.js +768 -0
  14. package/src/core/Interceptors.js +420 -0
  15. package/src/core/MessageBus.js +310 -0
  16. package/src/core/Prometheus.js +305 -0
  17. package/src/core/RequestContext.js +413 -0
  18. package/src/core/RoutingStrategy.js +316 -0
  19. package/src/core/Supervisor.js +1306 -0
  20. package/src/core/ThreadAllocator.js +196 -0
  21. package/src/core/WorkerChannelManager.js +879 -0
  22. package/src/core/config.js +624 -0
  23. package/src/core/host-config.js +311 -0
  24. package/src/core/network-utils.js +166 -0
  25. package/src/core/platform-config.js +308 -0
  26. package/src/decorators/ServiceProxy.js +899 -0
  27. package/src/decorators/index.js +571 -0
  28. package/src/deploy/NginxGenerator.js +865 -0
  29. package/src/deploy/PlatformManifestGenerator.js +96 -0
  30. package/src/deploy/RouteManifestGenerator.js +112 -0
  31. package/src/deploy/index.js +984 -0
  32. package/src/frontend/FrontendDevLifecycle.js +65 -0
  33. package/src/frontend/FrontendPluginOrchestrator.js +187 -0
  34. package/src/frontend/SiteResolver.js +63 -0
  35. package/src/frontend/StaticMountRegistry.js +90 -0
  36. package/src/frontend/index.js +5 -0
  37. package/src/frontend/plugins/index.js +2 -0
  38. package/src/frontend/plugins/viteFrontend.js +79 -0
  39. package/src/frontend/types.js +35 -0
  40. package/src/index.js +56 -0
  41. package/src/internals.js +31 -0
  42. package/src/plugins/PluginManager.js +537 -0
  43. package/src/plugins/ScopedPostgres.js +192 -0
  44. package/src/plugins/ScopedRedis.js +142 -0
  45. package/src/plugins/index.js +1729 -0
  46. package/src/registry/ServiceRegistry.js +796 -0
  47. package/src/scaling/ScaleAdvisor.js +442 -0
  48. package/src/services/Service.js +195 -0
  49. package/src/services/worker-bootstrap.js +676 -0
  50. package/src/templates/auth-service.js +65 -0
  51. package/src/templates/identity-service.js +75 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * ForgeHost Configuration
3
+ *
4
+ * Transforms a multi-project host config into a flat service map
5
+ * that feeds directly into defineServices(). Each project's services
6
+ * get namespaced (blog:api), grouped (blog:groupname), and assigned
7
+ * sequential ports starting from a base port.
8
+ *
9
+ * Usage:
10
+ *
11
+ * import { defineHost } from 'threadforge';
12
+ *
13
+ * export default defineHost({
14
+ * domain: 'myhost.dev',
15
+ * projects: {
16
+ * blog: {
17
+ * domain: 'blog.myhost.dev',
18
+ * config: './projects/blog/forge.config.js',
19
+ * },
20
+ * gameapi: {
21
+ * domain: 'game.myhost.dev',
22
+ * config: './projects/gameapi/forge.config.js',
23
+ * },
24
+ * },
25
+ * shared: {
26
+ * auth: {
27
+ * entry: './shared/auth.js',
28
+ * type: 'edge',
29
+ * port: 3100,
30
+ * },
31
+ * },
32
+ * plugins: [postgres(), redis()],
33
+ * });
34
+ */
35
+
36
+ import path from "node:path";
37
+ import { pathToFileURL } from "node:url";
38
+ import { defineServices, loadConfig } from "./config.js";
39
+ const BASE_PATH_RE = /^\/[^\s?#]*$/;
40
+
41
+ function isPlainObject(value) {
42
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
43
+ }
44
+
45
+ function normalizeFrontendConfig(projectId, frontend) {
46
+ if (!isPlainObject(frontend)) {
47
+ throw new Error(`Project "${projectId}" frontend must be an object`);
48
+ }
49
+
50
+ if (typeof frontend.plugin !== "string" || frontend.plugin.trim() === "") {
51
+ throw new Error(`Project "${projectId}" frontend.plugin must be a non-empty string`);
52
+ }
53
+ if (typeof frontend.root !== "string" || frontend.root.trim() === "") {
54
+ throw new Error(`Project "${projectId}" frontend.root must be a non-empty string`);
55
+ }
56
+ if (frontend.outDir !== undefined && (typeof frontend.outDir !== "string" || frontend.outDir.trim() === "")) {
57
+ throw new Error(`Project "${projectId}" frontend.outDir must be a non-empty string when provided`);
58
+ }
59
+ if (frontend.basePath !== undefined) {
60
+ if (typeof frontend.basePath !== "string" || !BASE_PATH_RE.test(frontend.basePath)) {
61
+ throw new Error(
62
+ `Project "${projectId}" frontend.basePath must start with "/" and cannot include query strings or fragments`,
63
+ );
64
+ }
65
+ }
66
+ if (frontend.spaFallback !== undefined && typeof frontend.spaFallback !== "boolean") {
67
+ throw new Error(`Project "${projectId}" frontend.spaFallback must be a boolean when provided`);
68
+ }
69
+ if (frontend.env !== undefined && !isPlainObject(frontend.env)) {
70
+ throw new Error(`Project "${projectId}" frontend.env must be an object when provided`);
71
+ }
72
+ if (frontend.options !== undefined && !isPlainObject(frontend.options)) {
73
+ throw new Error(`Project "${projectId}" frontend.options must be an object when provided`);
74
+ }
75
+
76
+ return {
77
+ plugin: frontend.plugin,
78
+ root: frontend.root,
79
+ outDir: frontend.outDir ?? `.threadforge/build/${projectId}`,
80
+ basePath: frontend.basePath ?? "/",
81
+ spaFallback: frontend.spaFallback ?? true,
82
+ env: frontend.env ?? {},
83
+ options: frontend.options ?? {},
84
+ };
85
+ }
86
+
87
+ function normalizeProjectDomains(project = {}) {
88
+ if (Array.isArray(project.domains)) return project.domains;
89
+ if (typeof project.domain === "string" && project.domain.trim() !== "") return [project.domain];
90
+ return [];
91
+ }
92
+
93
+ function validateSiteCollisions(sites) {
94
+ const seen = new Set();
95
+ for (const site of Object.values(sites)) {
96
+ if (site.domains.length === 0) continue;
97
+ const domains = site.domains;
98
+ for (const domain of domains) {
99
+ const key = `${domain}|${site.basePath}`;
100
+ if (seen.has(key)) {
101
+ throw new Error(`Duplicate site mapping for domain "${domain}" and basePath "${site.basePath}"`);
102
+ }
103
+ seen.add(key);
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validate the top-level host config shape.
110
+ */
111
+ function validateHostConfig(hostConfig) {
112
+ if (!hostConfig || typeof hostConfig !== "object") {
113
+ throw new Error("defineHost() requires a host config object");
114
+ }
115
+
116
+ if (!hostConfig.projects || typeof hostConfig.projects !== "object") {
117
+ throw new Error("defineHost() requires a projects map");
118
+ }
119
+
120
+ const projectIds = Object.keys(hostConfig.projects);
121
+ if (projectIds.length === 0) {
122
+ throw new Error("defineHost() requires at least one project");
123
+ }
124
+
125
+ for (const id of projectIds) {
126
+ if (!/^[a-z][a-z0-9_-]*$/i.test(id)) {
127
+ throw new Error(
128
+ `Invalid project ID "${id}": must start with a letter and contain only letters, digits, hyphens, and underscores`,
129
+ );
130
+ }
131
+
132
+ const project = hostConfig.projects[id];
133
+ if (!project.config && !project.services) {
134
+ throw new Error(`Project "${id}" must have either a config path or inline services`);
135
+ }
136
+
137
+ if (project.frontend) {
138
+ normalizeFrontendConfig(id, project.frontend);
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Namespace a service map for a project.
145
+ *
146
+ * Service names get prefixed: api -> blog:api
147
+ * Groups get prefixed: core -> blog:core
148
+ * Connects get prefixed to reference namespaced names.
149
+ */
150
+ function namespaceServices(projectId, services, sharedServiceNames) {
151
+ const namespaced = {};
152
+
153
+ for (const [name, svc] of Object.entries(services)) {
154
+ const nsSvc = { ...svc, _projectId: projectId };
155
+
156
+ if (nsSvc.group) {
157
+ nsSvc.group = `${projectId}:${nsSvc.group}`;
158
+ }
159
+
160
+ if (nsSvc.connects) {
161
+ nsSvc.connects = nsSvc.connects.map((target) => {
162
+ if (sharedServiceNames.includes(target) || target.includes(":")) return target;
163
+ return `${projectId}:${target}`;
164
+ });
165
+ }
166
+
167
+ namespaced[`${projectId}:${name}`] = nsSvc;
168
+ }
169
+
170
+ return namespaced;
171
+ }
172
+
173
+ /**
174
+ * Resolve a host config: load each project's config, namespace services,
175
+ * assign ports, and merge into a flat service map.
176
+ *
177
+ * @param {Object} hostConfig - The host config from defineHost()
178
+ * @returns {Object} A flat services map ready for defineServices()
179
+ */
180
+ export async function resolveHostConfig(hostConfig) {
181
+ validateHostConfig(hostConfig);
182
+
183
+ let nextPort = hostConfig.basePort ?? 3200;
184
+ const shared = hostConfig.shared ?? {};
185
+ const sharedServiceNames = Object.keys(shared);
186
+ const sites = {};
187
+
188
+ const reservedPorts = new Set();
189
+ const mergedServices = {};
190
+ for (const [name, svc] of Object.entries(shared)) {
191
+ if (svc.port) reservedPorts.add(svc.port);
192
+ mergedServices[name] = { ...svc };
193
+ }
194
+
195
+ const hostMeta = {};
196
+
197
+ for (const [projectId, project] of Object.entries(hostConfig.projects)) {
198
+ let projectServices;
199
+ if (project.services) {
200
+ projectServices = project.services;
201
+ } else {
202
+ const configPath = path.resolve(process.cwd(), project.config);
203
+ const configUrl = pathToFileURL(configPath).href;
204
+ const loaded = await loadConfig(configUrl);
205
+ projectServices = loaded.services ?? loaded;
206
+ }
207
+
208
+ const namespaced = namespaceServices(projectId, projectServices, sharedServiceNames);
209
+
210
+ for (const [nsName, svc] of Object.entries(namespaced)) {
211
+ if (svc.type === "edge" && !svc.port) {
212
+ while (reservedPorts.has(nextPort)) nextPort++;
213
+ svc.port = nextPort;
214
+ reservedPorts.add(nextPort);
215
+ nextPort++;
216
+ }
217
+
218
+ if (svc.type === "edge" && sharedServiceNames.includes("auth")) {
219
+ if (!svc.connects) svc.connects = [];
220
+ if (!svc.connects.includes("auth")) {
221
+ svc.connects.push("auth");
222
+ }
223
+ }
224
+
225
+ mergedServices[nsName] = svc;
226
+ }
227
+
228
+ hostMeta[projectId] = {
229
+ domain: project.domain ?? null,
230
+ services: Object.keys(namespaced),
231
+ schema: project.schema ?? `project_${projectId}`,
232
+ keyPrefix: `${projectId}:`,
233
+ };
234
+
235
+ const frontend = project.frontend ? normalizeFrontendConfig(projectId, project.frontend) : null;
236
+ if (frontend && project.static) {
237
+ console.warn(
238
+ `[ThreadForge] Project "${projectId}" defines both "frontend" and legacy "static". ` +
239
+ `Using frontend plugin config and ignoring "static".`,
240
+ );
241
+ }
242
+ sites[projectId] = {
243
+ siteId: projectId,
244
+ domains: normalizeProjectDomains(project),
245
+ basePath: frontend?.basePath ?? "/",
246
+ services: Object.keys(namespaced),
247
+ frontend,
248
+ staticDir: frontend ? null : (project.static ?? null),
249
+ };
250
+ }
251
+
252
+ validateSiteCollisions(sites);
253
+
254
+ return {
255
+ services: mergedServices,
256
+ hostMeta,
257
+ sites,
258
+ plugins: hostConfig.plugins ?? [],
259
+ frontendPlugins: hostConfig.frontendPlugins ?? [],
260
+ domain: hostConfig.domain ?? null,
261
+ metricsPort: hostConfig.metricsPort ?? 9090,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Define a multi-project host config.
267
+ *
268
+ * Returns a config object marked as host mode that ForgeHost
269
+ * can process. Can also be resolved immediately for direct use.
270
+ */
271
+ export function defineHost(hostConfig) {
272
+ validateHostConfig(hostConfig);
273
+
274
+ return {
275
+ _isHostConfig: true,
276
+ _raw: hostConfig,
277
+ domain: hostConfig.domain ?? null,
278
+ projects: hostConfig.projects,
279
+ shared: hostConfig.shared ?? {},
280
+ plugins: hostConfig.plugins ?? [],
281
+ frontendPlugins: hostConfig.frontendPlugins ?? [],
282
+ basePort: hostConfig.basePort ?? 3200,
283
+ metricsPort: hostConfig.metricsPort ?? 9090,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Resolve a host config and feed it into defineServices().
289
+ *
290
+ * @param {Object} hostConfig - Output from defineHost()
291
+ * @returns {Object} A fully resolved config ready for Supervisor
292
+ */
293
+ export async function resolveAndDefine(hostConfig) {
294
+ const raw = hostConfig._isHostConfig ? hostConfig._raw : hostConfig;
295
+ const resolved = await resolveHostConfig(raw);
296
+
297
+ const config = defineServices(resolved.services, {
298
+ plugins: resolved.plugins,
299
+ frontendPlugins: resolved.frontendPlugins,
300
+ sites: resolved.sites,
301
+ metricsPort: resolved.metricsPort,
302
+ });
303
+
304
+ // Mark as host mode for downstream consumers
305
+ config._isHostMode = true;
306
+ config._hostMeta = resolved.hostMeta;
307
+ config._hostDomain = resolved.domain;
308
+ config._sites = resolved.sites;
309
+
310
+ return config;
311
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Network utility functions for ThreadForge.
3
+ *
4
+ * Extracted from ForgeContext.js (A10) so other modules can reuse
5
+ * isPrivateNetwork, matchCIDR, ipToInt, and isTrustedProxy without
6
+ * pulling in the full ForgeContext dependency graph.
7
+ */
8
+
9
+ // P12: Cache parsed FORGE_TRUSTED_NETWORKS — re-parse only when the env var changes
10
+ let _trustedNetworksRaw = undefined;
11
+ let _trustedNetworksParsed = [];
12
+ let _ipv6Warned = false;
13
+
14
+ function _getTrustedNetworks() {
15
+ const raw = process.env.FORGE_TRUSTED_NETWORKS;
16
+ if (raw === _trustedNetworksRaw) return _trustedNetworksParsed;
17
+ _trustedNetworksRaw = raw;
18
+ if (!raw) {
19
+ _trustedNetworksParsed = [];
20
+ return _trustedNetworksParsed;
21
+ }
22
+ const result = [];
23
+ for (const cidr of raw.split(",")) {
24
+ const trimmed = cidr.trim();
25
+ if (!trimmed) continue;
26
+ if (trimmed.includes(":")) {
27
+ if (!_ipv6Warned) {
28
+ _ipv6Warned = true;
29
+ process.stderr.write(
30
+ JSON.stringify({
31
+ timestamp: new Date().toISOString(),
32
+ level: "warn",
33
+ message: `FORGE_TRUSTED_NETWORKS contains IPv6 entry "${trimmed}" which is not supported. Only IPv4 CIDRs are supported.`,
34
+ }) + "\n"
35
+ );
36
+ }
37
+ continue;
38
+ }
39
+ result.push(trimmed);
40
+ }
41
+ _trustedNetworksParsed = result;
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Convert an IPv4 dotted-quad string to a 32-bit unsigned integer.
47
+ * Returns null if the address is malformed.
48
+ * @param {string} ip
49
+ * @returns {number|null}
50
+ */
51
+ export function ipToInt(ip) {
52
+ const parts = ip.split(".");
53
+ if (parts.length !== 4) return null;
54
+ let result = 0;
55
+ for (const part of parts) {
56
+ const n = parseInt(part, 10);
57
+ if (Number.isNaN(n) || n < 0 || n > 255) return null;
58
+ result = (result << 8) | n;
59
+ }
60
+ return result >>> 0;
61
+ }
62
+
63
+ /**
64
+ * Basic CIDR match for IPv4. Matches address against prefix/length.
65
+ * @param {string} addr - IPv4 address
66
+ * @param {string} cidr - CIDR notation (e.g., "10.0.0.0/8")
67
+ * @returns {boolean}
68
+ */
69
+ export function matchCIDR(addr, cidr) {
70
+ const [prefix, lenStr] = cidr.split("/");
71
+ if (!prefix || !lenStr) return false;
72
+
73
+ const maskLen = parseInt(lenStr, 10);
74
+ if (Number.isNaN(maskLen) || maskLen < 0 || maskLen > 32) return false;
75
+
76
+ const addrNum = ipToInt(addr);
77
+ const prefixNum = ipToInt(prefix);
78
+ if (addrNum === null || prefixNum === null) return false;
79
+
80
+ const mask = maskLen === 0 ? 0 : (~0 << (32 - maskLen)) >>> 0;
81
+ return (addrNum & mask) === (prefixNum & mask);
82
+ }
83
+
84
+ /**
85
+ * Normalize a raw socket address by stripping IPv6 zone IDs and
86
+ * IPv6-mapped IPv4 prefixes.
87
+ * @param {string} address
88
+ * @returns {string}
89
+ */
90
+ export function normalizeAddress(address) {
91
+ // Strip IPv6 zone ID suffix (e.g., %eth0)
92
+ const noZone = address.split("%")[0];
93
+ // S5: Tighten IPv6-mapped IPv4 regex — require exactly 5 zero groups before ffff:
94
+ // Matches forms like 0:0:0:0:0:ffff:x.x.x.x or 0000:0000:0000:0000:0000:ffff:x.x.x.x
95
+ return noZone
96
+ .toLowerCase()
97
+ .replace(/^(?:0{1,4}:){5}(?:0{1,4}:)?ffff:/, "")
98
+ .replace(/^::ffff:/, "");
99
+ }
100
+
101
+ /**
102
+ * Check if a remote address is from a private/trusted network.
103
+ *
104
+ * Covers RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16),
105
+ * loopback (127.0.0.0/8, ::1), IPv6-mapped IPv4 (::ffff:x.x.x.x),
106
+ * and custom trusted networks via FORGE_TRUSTED_NETWORKS env var.
107
+ *
108
+ * @param {string} address - Remote socket address
109
+ * @returns {boolean}
110
+ */
111
+ export function isPrivateNetwork(address) {
112
+ if (!address) return false;
113
+
114
+ const addr = normalizeAddress(address);
115
+
116
+ // Loopback
117
+ if (addr === "127.0.0.1" || addr === "::1" || addr.startsWith("127.")) return true;
118
+
119
+ // IPv6 link-local (fe80::/10)
120
+ if (/^fe[89ab][0-9a-f]:/i.test(addr)) return true;
121
+
122
+ // IPv6 unique local (fc00::/7)
123
+ if (/^f[cd][0-9a-f]{2}:/i.test(addr)) return true;
124
+
125
+ // RFC 1918 private ranges
126
+ if (addr.startsWith("10.")) return true;
127
+ if (addr.startsWith("192.168.")) return true;
128
+
129
+ // 172.16.0.0/12 — covers 172.16.x.x through 172.31.x.x
130
+ if (addr.startsWith("172.")) {
131
+ const second = parseInt(addr.split(".")[1], 10);
132
+ if (second >= 16 && second <= 31) return true;
133
+ }
134
+
135
+ // Link-local (169.254.0.0/16)
136
+ if (addr.startsWith("169.254.")) return true;
137
+
138
+ // P12: Use cached trusted networks (re-parsed only when env var changes)
139
+ for (const cidr of _getTrustedNetworks()) {
140
+ if (matchCIDR(addr, cidr)) return true;
141
+ }
142
+
143
+ return false;
144
+ }
145
+
146
+ /**
147
+ * Check if a remote address is a trusted proxy.
148
+ * Only trusts addresses listed in FORGE_TRUSTED_PROXIES env var.
149
+ * @param {string} address
150
+ * @returns {boolean}
151
+ */
152
+ export function isTrustedProxy(address) {
153
+ const trusted = process.env.FORGE_TRUSTED_PROXIES;
154
+ if (!trusted) return false;
155
+ if (!address) return false;
156
+ const addr = normalizeAddress(address);
157
+ for (const entry of trusted.split(",")) {
158
+ const trimmed = entry.trim();
159
+ if (trimmed.includes("/")) {
160
+ if (matchCIDR(addr, trimmed)) return true;
161
+ } else if (addr === trimmed) {
162
+ return true;
163
+ }
164
+ }
165
+ return false;
166
+ }