gorsee 0.2.8 → 0.2.10
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 +8 -0
- package/dist-pkg/cli/check-ast.d.ts +3 -0
- package/dist-pkg/cli/check-ast.js +19 -0
- package/dist-pkg/cli/cmd-check.js +72 -1
- package/dist-pkg/cli/cmd-upgrade.d.ts +1 -1
- package/dist-pkg/cli/cmd-upgrade.js +3 -3
- package/dist-pkg/cli/framework-md.js +3 -0
- package/dist-pkg/prod.js +9 -2
- package/dist-pkg/runtime/app-config.d.ts +14 -0
- package/dist-pkg/runtime/app-config.js +7 -1
- package/dist-pkg/server/index.d.ts +1 -0
- package/dist-pkg/server/index.js +1 -0
- package/dist-pkg/server/redis-client.d.ts +3 -0
- package/dist-pkg/server/redis-client.js +2 -1
- package/dist-pkg/server/redis-job-queue.d.ts +10 -0
- package/dist-pkg/server/redis-job-queue.js +141 -0
- package/dist-pkg/server-entry.d.ts +1 -0
- package/dist-pkg/server-entry.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,6 +175,7 @@ Capabilities are not bolt-on features. They are part of the framework contract:
|
|
|
175
175
|
- auth and session stores
|
|
176
176
|
- request policy and security validation
|
|
177
177
|
- route cache with explicit intent
|
|
178
|
+
- single-instance defaults plus explicit multi-instance runtime contracts
|
|
178
179
|
- type-safe route generation
|
|
179
180
|
- validated forms
|
|
180
181
|
- deploy adapters with provider assumptions
|
|
@@ -219,6 +220,13 @@ const userRoute = createTypedRoute("/users/[id]")
|
|
|
219
220
|
- Use `gorsee/compat` only for explicit legacy migration semantics.
|
|
220
221
|
- `Link` prefetch is explicit: `prefetch={true}` is eager, `prefetch="hover"` is pointer/focus triggered, and `prefetch="viewport"` is IntersectionObserver-driven. Links without `prefetch` do not prefetch implicitly.
|
|
221
222
|
|
|
223
|
+
## Multi-Instance Runtime
|
|
224
|
+
|
|
225
|
+
- Gorsee defaults remain safe for single-node apps, but multi-instance deployments must be declared explicitly with `runtime.topology = "multi-instance"` in `app.config.ts`.
|
|
226
|
+
- In multi-instance mode, production runtime fails closed unless `security.rateLimit.limiter` is configured with a distributed backend such as `createRedisRateLimiter(...)`.
|
|
227
|
+
- Use `createRedisSessionStore(...)`, `createRedisCacheStore(...)`, and `createRedisJobQueue(...)` for session/cache/jobs when replicas must share state.
|
|
228
|
+
- Keep `.gorsee/*` local artifacts for node-local triage, and add `ai.bridge.url` when fleet-level AI event aggregation is required.
|
|
229
|
+
|
|
222
230
|
## Package Distribution
|
|
223
231
|
|
|
224
232
|
- workspace development stays Bun-first and source-first
|
|
@@ -10,7 +10,10 @@ export interface ASTFileFacts {
|
|
|
10
10
|
issues: ASTCheckIssue[];
|
|
11
11
|
hasServerCall: boolean;
|
|
12
12
|
hasRouteCacheCall: boolean;
|
|
13
|
+
hasRouteCacheExplicitStore: boolean;
|
|
13
14
|
hasCreateAuthCall: boolean;
|
|
15
|
+
hasCreateMemorySessionStoreCall: boolean;
|
|
16
|
+
hasCreateMemoryJobQueueCall: boolean;
|
|
14
17
|
hasRedirectCall: boolean;
|
|
15
18
|
hasCtxRedirectCall: boolean;
|
|
16
19
|
hasLegacyLoaderExport: boolean;
|
|
@@ -45,6 +45,16 @@ function hasRouteCacheIntent(ts, expr) {
|
|
|
45
45
|
return name === "includeAuthHeaders" || name === "vary" || name === "key" || name === "mode";
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
+
function hasRouteCacheStore(ts, expr) {
|
|
49
|
+
const firstArg = expr.arguments[0];
|
|
50
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
|
|
51
|
+
return !1;
|
|
52
|
+
return firstArg.properties.some((prop) => {
|
|
53
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop))
|
|
54
|
+
return !1;
|
|
55
|
+
return prop.name?.getText(firstArg.getSourceFile()).replace(/['"]/g, "") === "store";
|
|
56
|
+
});
|
|
57
|
+
}
|
|
48
58
|
export async function analyzeFileWithAST(file, cwd, content) {
|
|
49
59
|
const ts = await loadTypeScript();
|
|
50
60
|
if (!ts)
|
|
@@ -53,7 +63,10 @@ export async function analyzeFileWithAST(file, cwd, content) {
|
|
|
53
63
|
issues,
|
|
54
64
|
hasServerCall: !1,
|
|
55
65
|
hasRouteCacheCall: !1,
|
|
66
|
+
hasRouteCacheExplicitStore: !1,
|
|
56
67
|
hasCreateAuthCall: !1,
|
|
68
|
+
hasCreateMemorySessionStoreCall: !1,
|
|
69
|
+
hasCreateMemoryJobQueueCall: !1,
|
|
57
70
|
hasRedirectCall: !1,
|
|
58
71
|
hasCtxRedirectCall: !1,
|
|
59
72
|
hasLegacyLoaderExport: moduleFacts.exportedNames.has("loader"),
|
|
@@ -73,8 +86,14 @@ export async function analyzeFileWithAST(file, cwd, content) {
|
|
|
73
86
|
facts.hasServerCall = !0;
|
|
74
87
|
if (isIdentifier(ts, node.expression, "createAuth"))
|
|
75
88
|
facts.hasCreateAuthCall = !0;
|
|
89
|
+
if (isIdentifier(ts, node.expression, "createMemorySessionStore"))
|
|
90
|
+
facts.hasCreateMemorySessionStoreCall = !0;
|
|
91
|
+
if (isIdentifier(ts, node.expression, "createMemoryJobQueue"))
|
|
92
|
+
facts.hasCreateMemoryJobQueueCall = !0;
|
|
76
93
|
if (isIdentifier(ts, node.expression, "routeCache")) {
|
|
77
94
|
facts.hasRouteCacheCall = !0;
|
|
95
|
+
if (hasRouteCacheStore(ts, node))
|
|
96
|
+
facts.hasRouteCacheExplicitStore = !0;
|
|
78
97
|
if (!hasRouteCacheIntent(ts, node))
|
|
79
98
|
pushIssue(issues, seen, {
|
|
80
99
|
code: "W904",
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
emitAIDiagnostic,
|
|
12
12
|
emitAIEvent
|
|
13
13
|
} from "../ai/index.js";
|
|
14
|
-
import { loadAppConfig, resolveAIConfig } from "../runtime/app-config.js";
|
|
14
|
+
import { loadAppConfig, resolveAIConfig, resolveRuntimeTopology } from "../runtime/app-config.js";
|
|
15
15
|
const MAX_FILE_LINES = 500;
|
|
16
16
|
async function getAllTsFiles(dir) {
|
|
17
17
|
const files = [];
|
|
@@ -32,6 +32,25 @@ async function getAllTsFiles(dir) {
|
|
|
32
32
|
}
|
|
33
33
|
return files;
|
|
34
34
|
}
|
|
35
|
+
async function getTopLevelTsFiles(cwd) {
|
|
36
|
+
const files = [];
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = await readdir(cwd);
|
|
40
|
+
} catch {
|
|
41
|
+
return files;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry === "node_modules" || entry === "dist" || entry === ".git" || entry === "routes" || entry === "shared" || entry === "middleware")
|
|
45
|
+
continue;
|
|
46
|
+
if (!entry.endsWith(".ts") && !entry.endsWith(".tsx"))
|
|
47
|
+
continue;
|
|
48
|
+
const fullPath = join(cwd, entry);
|
|
49
|
+
if ((await stat(fullPath)).isFile())
|
|
50
|
+
files.push(fullPath);
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
35
54
|
async function checkFileSize(file, cwd) {
|
|
36
55
|
const issues = [], lines = (await readFile(file, "utf-8")).split(`
|
|
37
56
|
`).length, rel = relative(cwd, file);
|
|
@@ -174,6 +193,54 @@ async function checkSecurityContracts(cwd, files, astFacts) {
|
|
|
174
193
|
});
|
|
175
194
|
return issues;
|
|
176
195
|
}
|
|
196
|
+
async function checkDistributedContracts(cwd, files, astFacts) {
|
|
197
|
+
const issues = [], appConfig = await loadAppConfig(cwd);
|
|
198
|
+
if (resolveRuntimeTopology(appConfig) !== "multi-instance")
|
|
199
|
+
return issues;
|
|
200
|
+
const hasDistributedRateLimiter = Boolean(appConfig.security?.rateLimit?.limiter), hasAIBridge = Boolean(appConfig.ai?.bridge?.url);
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
const facts = astFacts.get(file);
|
|
203
|
+
if (!facts)
|
|
204
|
+
continue;
|
|
205
|
+
const rel = relative(cwd, file);
|
|
206
|
+
if (facts.hasCreateMemorySessionStoreCall)
|
|
207
|
+
issues.push({
|
|
208
|
+
code: "W917",
|
|
209
|
+
file: rel,
|
|
210
|
+
message: "multi-instance app uses createMemorySessionStore(), which is process-local only",
|
|
211
|
+
fix: "Use a distributed session store such as createRedisSessionStore(...) for multi-instance deployments"
|
|
212
|
+
});
|
|
213
|
+
if (facts.hasCreateMemoryJobQueueCall)
|
|
214
|
+
issues.push({
|
|
215
|
+
code: "W918",
|
|
216
|
+
file: rel,
|
|
217
|
+
message: "multi-instance app uses createMemoryJobQueue(), which does not provide durable cross-replica execution",
|
|
218
|
+
fix: "Use createRedisJobQueue(...) or another durable distributed queue backend for multi-instance deployments"
|
|
219
|
+
});
|
|
220
|
+
if (facts.hasRouteCacheCall && !facts.hasRouteCacheExplicitStore)
|
|
221
|
+
issues.push({
|
|
222
|
+
code: "W919",
|
|
223
|
+
file: rel,
|
|
224
|
+
message: "multi-instance app uses routeCache() without an explicit distributed store",
|
|
225
|
+
fix: "Pass store: createRedisCacheStore(...) (or another shared CacheStore) so cache entries stay coherent across replicas"
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (!hasDistributedRateLimiter)
|
|
229
|
+
issues.push({
|
|
230
|
+
code: "W920",
|
|
231
|
+
file: "app.config.ts",
|
|
232
|
+
message: "multi-instance app does not declare security.rateLimit.limiter",
|
|
233
|
+
fix: "Set security.rateLimit.limiter to a distributed limiter such as createRedisRateLimiter(...) to avoid per-process quota drift"
|
|
234
|
+
});
|
|
235
|
+
if ((appConfig.ai?.enabled ?? !1) && !hasAIBridge)
|
|
236
|
+
issues.push({
|
|
237
|
+
code: "W921",
|
|
238
|
+
file: "app.config.ts",
|
|
239
|
+
message: "multi-instance app enables AI observability without ai.bridge.url",
|
|
240
|
+
fix: "Keep local .gorsee artifacts for node-local triage, but add ai.bridge.url to forward events to a fleet-level aggregator"
|
|
241
|
+
});
|
|
242
|
+
return issues;
|
|
243
|
+
}
|
|
177
244
|
function checkImportContracts(cwd, astFacts) {
|
|
178
245
|
const issues = [];
|
|
179
246
|
for (const [file, facts] of astFacts) {
|
|
@@ -451,6 +518,7 @@ export async function checkProject(options = {}) {
|
|
|
451
518
|
const files = await getAllTsFiles(paths.routesDir);
|
|
452
519
|
files.push(...await getAllTsFiles(paths.sharedDir));
|
|
453
520
|
files.push(...await getAllTsFiles(paths.middlewareDir));
|
|
521
|
+
files.push(...await getTopLevelTsFiles(cwd));
|
|
454
522
|
const astFacts = await collectASTFacts(files, cwd);
|
|
455
523
|
for (const file of files) {
|
|
456
524
|
const sizeIssues = await checkFileSize(file, cwd), safetyIssues = await checkUnsafePatterns(file, cwd);
|
|
@@ -463,6 +531,9 @@ export async function checkProject(options = {}) {
|
|
|
463
531
|
const importIssues = checkImportContracts(cwd, astFacts);
|
|
464
532
|
for (const issue of importIssues)
|
|
465
533
|
pushIssue(result, issue, strictSecurity);
|
|
534
|
+
const distributedIssues = await checkDistributedContracts(cwd, files, astFacts);
|
|
535
|
+
for (const issue of distributedIssues)
|
|
536
|
+
pushIssue(result, issue, strictSecurity);
|
|
466
537
|
const dependencyIssues = await checkDependencyPolicy(cwd);
|
|
467
538
|
for (const issue of dependencyIssues)
|
|
468
539
|
pushIssue(result, issue, strictSecurity);
|
|
@@ -47,7 +47,7 @@ export interface UpgradeCommandOptions extends RuntimeOptions {
|
|
|
47
47
|
export declare function performUpgrade(cwd: string, flags: UpgradeFlags, hooks?: {
|
|
48
48
|
fetchLatestVersion?: () => Promise<string | null>;
|
|
49
49
|
getCurrentVersion?: (cwd: string) => Promise<string | null>;
|
|
50
|
-
runInstallStep?: (cwd: string) => Promise<UpgradeStepResult>;
|
|
50
|
+
runInstallStep?: (cwd: string, version: string) => Promise<UpgradeStepResult>;
|
|
51
51
|
runCheckStep?: (cwd: string) => Promise<UpgradeStepResult>;
|
|
52
52
|
}): Promise<UpgradeExecutionResult | null>;
|
|
53
53
|
export declare function upgradeFramework(args: string[], options?: UpgradeCommandOptions): Promise<void>;
|
|
@@ -154,8 +154,8 @@ async function runProcess(command, cwd) {
|
|
|
154
154
|
}).exited;
|
|
155
155
|
return { command, exitCode };
|
|
156
156
|
}
|
|
157
|
-
async function runInstallStep(cwd) {
|
|
158
|
-
return runProcess(["bun", "add", "gorsee
|
|
157
|
+
async function runInstallStep(cwd, version) {
|
|
158
|
+
return runProcess(["bun", "add", "--exact", `gorsee@${version}`], cwd);
|
|
159
159
|
}
|
|
160
160
|
async function runCheckStep(cwd) {
|
|
161
161
|
return runProcess(["bun", "run", "check"], cwd);
|
|
@@ -249,7 +249,7 @@ export async function performUpgrade(cwd, flags, hooks = {}) {
|
|
|
249
249
|
}
|
|
250
250
|
console.log(`
|
|
251
251
|
Installing gorsee@latest...`);
|
|
252
|
-
const installResult = await runInstallStepImpl(cwd);
|
|
252
|
+
const installResult = await runInstallStepImpl(cwd, latest);
|
|
253
253
|
if (installResult.exitCode !== 0) {
|
|
254
254
|
console.log(`
|
|
255
255
|
Install failed (exit code ${installResult.exitCode})
|
|
@@ -109,6 +109,8 @@ export const cache = routeCache({
|
|
|
109
109
|
|
|
110
110
|
- SQLite adapters are the default persistent single-node path
|
|
111
111
|
- Redis adapters are the default multi-instance path
|
|
112
|
+
- Set \`runtime.topology = "multi-instance"\` in \`app.config.ts\` when replicas share traffic; production then requires an explicit distributed \`security.rateLimit.limiter\`
|
|
113
|
+
- Use \`createRedisJobQueue()\` for durable cross-replica job execution; \`createMemoryJobQueue()\` is single-node only
|
|
112
114
|
- \`createNodeRedisLikeClient()\` and \`createIORedisLikeClient()\` normalize real Redis SDK clients to the framework adapter contract
|
|
113
115
|
- \`routeCache()\` defaults to \`mode: "private"\` and varies by \`Cookie\`, \`Authorization\`, \`Accept\`, and \`X-Gorsee-Navigate\`
|
|
114
116
|
- Use \`mode: "public"\` or \`mode: "shared"\` only for intentionally non-personalized cache entries
|
|
@@ -167,6 +169,7 @@ export default {
|
|
|
167
169
|
- \`gorsee ai doctor\` clusters repeated failures by trace/request/file/route so agents can spot systemic regressions quickly
|
|
168
170
|
- \`gorsee ai doctor\` and \`gorsee ai export\` also surface artifact regressions, so agents can reason about broken tarballs, VSIX files, build outputs, and deploy configs without separate tooling
|
|
169
171
|
- Bridge delivery is best-effort only; a dead IDE bridge must never fail the request/build/check path
|
|
172
|
+
- In multi-instance deployments, keep local \`.gorsee/*\` artifacts for node-local triage and add \`ai.bridge.url\` for fleet-level aggregation
|
|
170
173
|
- Event schema carries \`requestId\`, \`traceId\`, \`spanId\`, \`route\`, \`code\`, \`file\`, \`line\`, and \`durationMs\`
|
|
171
174
|
- Prefer AI events over scraped console logs when automating analysis
|
|
172
175
|
- Use \`gorsee ai export --bundle\` when an agent needs a compact context packet plus root-cause-ranked code snippets
|
package/dist-pkg/prod.js
CHANGED
|
@@ -33,7 +33,9 @@ import { createProjectContext, resolveRuntimeEnv } from "./runtime/project.js";
|
|
|
33
33
|
import {
|
|
34
34
|
loadAppConfig,
|
|
35
35
|
resolveAIConfig,
|
|
36
|
+
resolveRuntimeTopology,
|
|
36
37
|
resolveRPCMiddlewares,
|
|
38
|
+
resolveSecurityRateLimit,
|
|
37
39
|
resolveTrustedHosts,
|
|
38
40
|
resolveTrustedForwardedHops,
|
|
39
41
|
resolveTrustedOrigin,
|
|
@@ -284,14 +286,19 @@ async function loadProductionRuntimeState(options = {}) {
|
|
|
284
286
|
await loadEnv(runtime.cwd);
|
|
285
287
|
const envConfig = resolveRuntimeEnv(process.env);
|
|
286
288
|
setLogLevel(envConfig.logLevel);
|
|
287
|
-
const manifest = await loadBuildManifest(runtime.paths.distDir);
|
|
289
|
+
const appConfig = await loadAppConfig(runtime.cwd, runtime.paths.appConfigFile), manifest = await loadBuildManifest(runtime.paths.distDir);
|
|
288
290
|
log.info("loaded manifest", {
|
|
289
291
|
routes: Object.keys(manifest.routes).length,
|
|
290
292
|
built: manifest.buildTime,
|
|
291
293
|
schemaVersion: manifest.schemaVersion,
|
|
292
294
|
expectedSchemaVersion: BUILD_MANIFEST_SCHEMA_VERSION
|
|
293
295
|
});
|
|
294
|
-
const routes = await createRouter(runtime.paths.routesDir), staticMap = buildStaticMap(routes), rateLimiter = createRateLimiter(envConfig.rateLimit, envConfig.rateWindow);
|
|
296
|
+
const routes = await createRouter(runtime.paths.routesDir), staticMap = buildStaticMap(routes), topology = resolveRuntimeTopology(appConfig), configuredRateLimit = resolveSecurityRateLimit(appConfig), rateLimiter = configuredRateLimit?.limiter ?? (topology === "multi-instance" ? null : createRateLimiter(configuredRateLimit?.maxRequests ?? envConfig.rateLimit, configuredRateLimit?.window ?? envConfig.rateWindow));
|
|
297
|
+
if (!rateLimiter)
|
|
298
|
+
throw Error([
|
|
299
|
+
"Multi-instance production runtime requires security.rateLimit.limiter in app.config.ts.",
|
|
300
|
+
"Use a distributed limiter such as createRedisRateLimiter(...) instead of the process-local default."
|
|
301
|
+
].join(" "));
|
|
295
302
|
return {
|
|
296
303
|
cwd: runtime.cwd,
|
|
297
304
|
routesDir: runtime.paths.routesDir,
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { MiddlewareFn } from "../server/middleware.js";
|
|
2
2
|
import { type AIObservabilityConfig } from "../ai/index.js";
|
|
3
|
+
import type { AsyncRateLimiter } from "../security/redis-rate-limit.js";
|
|
4
|
+
import type { RateLimiter } from "../security/rate-limit.js";
|
|
5
|
+
export interface AppSecurityRateLimitConfig {
|
|
6
|
+
maxRequests?: number;
|
|
7
|
+
window?: string;
|
|
8
|
+
limiter?: RateLimiter | AsyncRateLimiter;
|
|
9
|
+
}
|
|
3
10
|
export interface AppConfig {
|
|
4
11
|
ai?: AIObservabilityConfig;
|
|
12
|
+
runtime?: {
|
|
13
|
+
topology?: RuntimeTopology;
|
|
14
|
+
};
|
|
5
15
|
security?: {
|
|
6
16
|
origin?: string;
|
|
7
17
|
hosts?: string[];
|
|
@@ -10,14 +20,18 @@ export interface AppConfig {
|
|
|
10
20
|
trustForwardedHeaders?: boolean;
|
|
11
21
|
trustedForwardedHops?: number;
|
|
12
22
|
};
|
|
23
|
+
rateLimit?: AppSecurityRateLimitConfig;
|
|
13
24
|
rpc?: {
|
|
14
25
|
middlewares?: MiddlewareFn[];
|
|
15
26
|
};
|
|
16
27
|
};
|
|
17
28
|
}
|
|
18
29
|
export type ProxyPreset = "none" | "reverse-proxy" | "vercel" | "netlify" | "fly" | "cloudflare";
|
|
30
|
+
export type RuntimeTopology = "single-instance" | "multi-instance";
|
|
19
31
|
export declare function loadAppConfig(cwd: string, explicitPath?: string): Promise<AppConfig>;
|
|
20
32
|
export declare function resolveRPCMiddlewares(config: AppConfig, explicitMiddlewares?: MiddlewareFn[]): MiddlewareFn[] | undefined;
|
|
33
|
+
export declare function resolveRuntimeTopology(config: AppConfig): RuntimeTopology;
|
|
34
|
+
export declare function resolveSecurityRateLimit(config: AppConfig): AppSecurityRateLimitConfig | undefined;
|
|
21
35
|
export declare function resolveAIConfig(cwd: string, config: AppConfig, explicitConfig?: AIObservabilityConfig): AIObservabilityConfig | undefined;
|
|
22
36
|
export declare function resolveTrustedOrigin(config: AppConfig, env?: NodeJS.ProcessEnv): string | undefined;
|
|
23
37
|
export declare function resolveTrustForwardedHeaders(config: AppConfig): boolean;
|
|
@@ -11,7 +11,7 @@ export async function loadAppConfig(cwd, explicitPath) {
|
|
|
11
11
|
for (const configPath of candidatePaths)
|
|
12
12
|
try {
|
|
13
13
|
const configStat = await stat(configPath);
|
|
14
|
-
return (await import(`${pathToFileURL(configPath).href}?t=${configStat.mtimeMs}`)).default ?? {};
|
|
14
|
+
return (await import(`${pathToFileURL(configPath).href}?t=${configStat.mtimeMs}-${Date.now()}`)).default ?? {};
|
|
15
15
|
} catch {
|
|
16
16
|
continue;
|
|
17
17
|
}
|
|
@@ -23,6 +23,12 @@ export function resolveRPCMiddlewares(config, explicitMiddlewares) {
|
|
|
23
23
|
const configMiddlewares = config.security?.rpc?.middlewares;
|
|
24
24
|
return configMiddlewares && configMiddlewares.length > 0 ? configMiddlewares : void 0;
|
|
25
25
|
}
|
|
26
|
+
export function resolveRuntimeTopology(config) {
|
|
27
|
+
return config.runtime?.topology === "multi-instance" ? "multi-instance" : "single-instance";
|
|
28
|
+
}
|
|
29
|
+
export function resolveSecurityRateLimit(config) {
|
|
30
|
+
return config.security?.rateLimit;
|
|
31
|
+
}
|
|
26
32
|
export function resolveAIConfig(cwd, config, explicitConfig) {
|
|
27
33
|
const merged = {
|
|
28
34
|
...config.ai ?? {},
|
|
@@ -15,6 +15,7 @@ export { createGuard, requireAuth, requireRole, allGuards, anyGuard } from "./gu
|
|
|
15
15
|
export { pipe, when, forMethods, forPaths } from "./pipe.js";
|
|
16
16
|
export { createNamespacedCacheStore } from "./cache-utils.js";
|
|
17
17
|
export { createRedisCacheStore } from "./redis-cache-store.js";
|
|
18
|
+
export { createRedisJobQueue, type RedisJobQueueOptions } from "./redis-job-queue.js";
|
|
18
19
|
export { createScopedRPCRegistry } from "./rpc-utils.js";
|
|
19
20
|
export { createSQLiteCacheStore } from "./sqlite-cache-store.js";
|
|
20
21
|
export { createMemoryJobQueue, defineJob, type JobContext, type JobDefinition, type JobEnqueueOptions, type JobQueue, type JobRunResult, type EnqueuedJob } from "./jobs.js";
|
package/dist-pkg/server/index.js
CHANGED
|
@@ -42,6 +42,7 @@ export { createGuard, requireAuth, requireRole, allGuards, anyGuard } from "./gu
|
|
|
42
42
|
export { pipe, when, forMethods, forPaths } from "./pipe.js";
|
|
43
43
|
export { createNamespacedCacheStore } from "./cache-utils.js";
|
|
44
44
|
export { createRedisCacheStore } from "./redis-cache-store.js";
|
|
45
|
+
export { createRedisJobQueue } from "./redis-job-queue.js";
|
|
45
46
|
export { createScopedRPCRegistry } from "./rpc-utils.js";
|
|
46
47
|
export { createSQLiteCacheStore } from "./sqlite-cache-store.js";
|
|
47
48
|
export { createMemoryJobQueue, defineJob } from "./jobs.js";
|
|
@@ -8,6 +8,7 @@ export interface RedisLikeClient {
|
|
|
8
8
|
expire?(key: string, seconds: number): Awaitable<number>;
|
|
9
9
|
pttl?(key: string): Awaitable<number>;
|
|
10
10
|
ttl?(key: string): Awaitable<number>;
|
|
11
|
+
setnx?(key: string, value: string): Awaitable<number>;
|
|
11
12
|
}
|
|
12
13
|
export declare function buildRedisKey(prefix: string, key: string): string;
|
|
13
14
|
export declare function stripRedisPrefix(prefix: string, key: string): string;
|
|
@@ -20,6 +21,7 @@ export interface NodeRedisClientLike {
|
|
|
20
21
|
expire?(key: string, seconds: number): Awaitable<number>;
|
|
21
22
|
pttl?(key: string): Awaitable<number>;
|
|
22
23
|
ttl?(key: string): Awaitable<number>;
|
|
24
|
+
setnx?(key: string, value: string): Awaitable<number>;
|
|
23
25
|
}
|
|
24
26
|
export interface IORedisClientLike {
|
|
25
27
|
get(key: string): Awaitable<string | null>;
|
|
@@ -30,6 +32,7 @@ export interface IORedisClientLike {
|
|
|
30
32
|
expire?(key: string, seconds: number): Awaitable<number>;
|
|
31
33
|
pttl?(key: string): Awaitable<number>;
|
|
32
34
|
ttl?(key: string): Awaitable<number>;
|
|
35
|
+
setnx?(key: string, value: string): Awaitable<number>;
|
|
33
36
|
}
|
|
34
37
|
export declare function createNodeRedisLikeClient(client: NodeRedisClientLike): RedisLikeClient;
|
|
35
38
|
export declare function createIORedisLikeClient(client: IORedisClientLike): RedisLikeClient;
|
|
@@ -14,7 +14,8 @@ export function createNodeRedisLikeClient(client) {
|
|
|
14
14
|
incr: client.incr ? (key) => client.incr(key) : void 0,
|
|
15
15
|
expire: client.expire ? (key, seconds) => client.expire(key, seconds) : void 0,
|
|
16
16
|
pttl: client.pttl ? (key) => client.pttl(key) : void 0,
|
|
17
|
-
ttl: client.ttl ? (key) => client.ttl(key) : void 0
|
|
17
|
+
ttl: client.ttl ? (key) => client.ttl(key) : void 0,
|
|
18
|
+
setnx: client.setnx ? (key, value) => client.setnx(key, value) : void 0
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
export function createIORedisLikeClient(client) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JobDefinition, JobQueue } from "./jobs.js";
|
|
2
|
+
import { type RedisLikeClient } from "./redis-client.js";
|
|
3
|
+
interface RedisJobQueueOptions {
|
|
4
|
+
prefix?: string;
|
|
5
|
+
lockTtlSeconds?: number;
|
|
6
|
+
jobs?: Array<JobDefinition<unknown>>;
|
|
7
|
+
instanceId?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function createRedisJobQueue(client: RedisLikeClient, options?: RedisJobQueueOptions): JobQueue;
|
|
10
|
+
export type { RedisJobQueueOptions };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { buildRedisKey, stripRedisPrefix } from "./redis-client.js";
|
|
2
|
+
export function createRedisJobQueue(client, options = {}) {
|
|
3
|
+
if (!client.incr || !client.expire || !client.setnx)
|
|
4
|
+
throw Error("Redis job queue requires incr(), expire(), and setnx() support on the Redis client.");
|
|
5
|
+
const prefix = options.prefix ?? "gorsee:jobs", lockTtlSeconds = options.lockTtlSeconds ?? 30, instanceId = options.instanceId ?? crypto.randomUUID(), handlers = new Map;
|
|
6
|
+
for (const job of options.jobs ?? [])
|
|
7
|
+
handlers.set(job.name, job);
|
|
8
|
+
return {
|
|
9
|
+
async enqueue(job, payload, enqueueOptions = {}) {
|
|
10
|
+
handlers.set(job.name, job);
|
|
11
|
+
const sequence = await client.incr(buildRedisKey(prefix, "__sequence")), id = `${job.name}:${sequence}:${crypto.randomUUID()}`, enqueued = {
|
|
12
|
+
id,
|
|
13
|
+
name: job.name,
|
|
14
|
+
payload,
|
|
15
|
+
runAt: enqueueOptions.runAt ?? Date.now(),
|
|
16
|
+
attempts: 0,
|
|
17
|
+
maxAttempts: enqueueOptions.maxAttempts ?? 3,
|
|
18
|
+
backoffMs: enqueueOptions.backoffMs ?? 1000
|
|
19
|
+
};
|
|
20
|
+
await client.set(jobKey(prefix, id), JSON.stringify({
|
|
21
|
+
...enqueued,
|
|
22
|
+
sequence
|
|
23
|
+
}));
|
|
24
|
+
return enqueued;
|
|
25
|
+
},
|
|
26
|
+
async runNext(now = Date.now()) {
|
|
27
|
+
const dueJobs = await listDueJobs(client, prefix, now);
|
|
28
|
+
for (const job of dueJobs) {
|
|
29
|
+
const lockKey = claimKey(prefix, job.id);
|
|
30
|
+
if (await client.setnx(lockKey, instanceId) !== 1)
|
|
31
|
+
continue;
|
|
32
|
+
await client.expire(lockKey, lockTtlSeconds);
|
|
33
|
+
try {
|
|
34
|
+
const current = await readStoredJob(client, prefix, job.id);
|
|
35
|
+
if (!current || current.runAt > now)
|
|
36
|
+
continue;
|
|
37
|
+
const handler = handlers.get(current.name);
|
|
38
|
+
if (!handler) {
|
|
39
|
+
await client.del(jobKey(prefix, current.id));
|
|
40
|
+
return {
|
|
41
|
+
id: current.id,
|
|
42
|
+
name: current.name,
|
|
43
|
+
status: "failed",
|
|
44
|
+
attempts: current.attempts + 1,
|
|
45
|
+
error: `Missing Redis job handler registration for "${current.name}"`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
current.attempts += 1;
|
|
49
|
+
try {
|
|
50
|
+
await handler.handler(current.payload, {
|
|
51
|
+
attempt: current.attempts,
|
|
52
|
+
maxAttempts: current.maxAttempts
|
|
53
|
+
});
|
|
54
|
+
await client.del(jobKey(prefix, current.id));
|
|
55
|
+
return {
|
|
56
|
+
id: current.id,
|
|
57
|
+
name: current.name,
|
|
58
|
+
status: "completed",
|
|
59
|
+
attempts: current.attempts
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
if (current.attempts < current.maxAttempts) {
|
|
64
|
+
current.lastError = message;
|
|
65
|
+
current.runAt = now + current.backoffMs * current.attempts;
|
|
66
|
+
await client.set(jobKey(prefix, current.id), JSON.stringify(current));
|
|
67
|
+
return {
|
|
68
|
+
id: current.id,
|
|
69
|
+
name: current.name,
|
|
70
|
+
status: "retrying",
|
|
71
|
+
attempts: current.attempts,
|
|
72
|
+
nextRunAt: current.runAt,
|
|
73
|
+
error: message
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
await client.del(jobKey(prefix, current.id));
|
|
77
|
+
return {
|
|
78
|
+
id: current.id,
|
|
79
|
+
name: current.name,
|
|
80
|
+
status: "failed",
|
|
81
|
+
attempts: current.attempts,
|
|
82
|
+
error: message
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
await client.del(lockKey);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
},
|
|
91
|
+
async drain(now = Date.now()) {
|
|
92
|
+
const results = [];
|
|
93
|
+
for (;; ) {
|
|
94
|
+
const result = await this.runNext(now);
|
|
95
|
+
if (!result)
|
|
96
|
+
return results;
|
|
97
|
+
results.push(result);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
async size() {
|
|
101
|
+
return (await listStoredJobs(client, prefix)).length;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function jobKey(prefix, id) {
|
|
106
|
+
return buildRedisKey(prefix, id);
|
|
107
|
+
}
|
|
108
|
+
function claimKey(prefix, id) {
|
|
109
|
+
return buildRedisKey(`${prefix}:lock`, id);
|
|
110
|
+
}
|
|
111
|
+
async function listStoredJobs(client, prefix) {
|
|
112
|
+
const keys = await client.keys(`${prefix}:*`), jobs = [];
|
|
113
|
+
for (const key of keys) {
|
|
114
|
+
const visibleKey = stripRedisPrefix(prefix, key);
|
|
115
|
+
if (visibleKey.startsWith("lock:") || visibleKey === "__sequence")
|
|
116
|
+
continue;
|
|
117
|
+
const job = await readStoredJob(client, prefix, visibleKey);
|
|
118
|
+
if (job)
|
|
119
|
+
jobs.push(job);
|
|
120
|
+
}
|
|
121
|
+
return jobs;
|
|
122
|
+
}
|
|
123
|
+
async function listDueJobs(client, prefix, now) {
|
|
124
|
+
return (await listStoredJobs(client, prefix)).filter((job) => job.runAt <= now).sort((a, b) => a.runAt - b.runAt || a.sequence - b.sequence);
|
|
125
|
+
}
|
|
126
|
+
async function readStoredJob(client, prefix, id) {
|
|
127
|
+
const raw = await client.get(jobKey(prefix, id));
|
|
128
|
+
if (!raw)
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(raw);
|
|
132
|
+
if (typeof parsed.id !== "string" || typeof parsed.name !== "string") {
|
|
133
|
+
await client.del(jobKey(prefix, id));
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return parsed;
|
|
137
|
+
} catch {
|
|
138
|
+
await client.del(jobKey(prefix, id));
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -13,6 +13,7 @@ export { log, setLogLevel } from "./log/index.js";
|
|
|
13
13
|
export { buildAIHealthReport, buildAIContextBundle, buildIDEProjection, configureAIObservability, createAIMCPServer, createAIContextPacket, createAIBridgeHandler, createAIBridgeServer, createIDEProjectionWatcher, createLineReader, emitAIDiagnostic, emitAIEvent, createTraceIds, readAIDiagnosticsSnapshot, readAIEvents, renderAIContextBundleMarkdown, renderAIContextMarkdown, resolveAISessionPackPaths, resolveIDEProjectionPaths, resolveAIStorePaths, resolveAIObservabilityConfig, runWithAITrace, writeAISessionPack, writeIDEProjection, type AIMCPServerOptions, type AIContextPacket, type AIContextBundle, type AIContextSnippet, type AIHealthReport, type AIEvent, type AIEventSeverity, type IDEProjection, type IDEProjectionPaths, type IDEProjectionWatcher, type IDEProjectionWatcherOptions, type AISessionPackConfig, type AISessionPackPaths, type AIStorePaths, type AITraceContext, type AIDiagnostic, type AIObservabilityConfig, type AIBridgeConfig, type AIBridgeServer, type AIBridgeHandler, type AIBridgeServerOptions, type AIBridgeSnapshot, } from "./ai/index.js";
|
|
14
14
|
export { createNodeRedisLikeClient, createIORedisLikeClient, deleteExpiredRedisKeys, type RedisLikeClient, type NodeRedisClientLike, type IORedisClientLike, } from "./server/redis-client.js";
|
|
15
15
|
export { createMemoryJobQueue, defineJob, type JobContext, type JobDefinition, type JobEnqueueOptions, type JobQueue, type JobRunResult, type EnqueuedJob, } from "./server/jobs.js";
|
|
16
|
+
export { createRedisJobQueue, type RedisJobQueueOptions } from "./server/redis-job-queue.js";
|
|
16
17
|
/** @deprecated Prefer "gorsee/i18n" when locale contracts are the primary concern. */
|
|
17
18
|
export { setupI18n, loadLocale, getLocale, getLocales, getDefaultLocale, getFallbackLocales, setLocale, t, plural, negotiateLocale, resolveLocaleFromPath, stripLocalePrefix, withLocalePath, buildHreflangLinks, formatNumber, formatDate, formatRelativeTime, type I18nConfig, type LocaleNegotiationInput, type LocaleNegotiationResult, } from "./i18n/index.js";
|
|
18
19
|
/** @deprecated Prefer "gorsee/content" when content contracts are the primary concern. */
|
package/dist-pkg/server-entry.js
CHANGED