gorsee 0.2.9 → 0.2.11

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 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
package/bin/gorsee.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from "node:fs"
4
+ import { dirname, join, resolve } from "node:path"
5
+ import { fileURLToPath, pathToFileURL } from "node:url"
6
+ import { spawnSync } from "node:child_process"
7
+
8
+ const binDir = dirname(fileURLToPath(import.meta.url))
9
+ const packageRoot = resolve(binDir, "..")
10
+ const publishedCliEntry = join(packageRoot, "dist-pkg", "cli", "index.js")
11
+ const sourceCliEntry = join(packageRoot, "src", "cli", "index.ts")
12
+
13
+ function runWithBun(entry) {
14
+ const result = spawnSync("bun", ["run", entry, ...process.argv.slice(2)], {
15
+ cwd: process.cwd(),
16
+ stdio: "inherit",
17
+ })
18
+ process.exit(result.status ?? 1)
19
+ }
20
+
21
+ function hasBun() {
22
+ const result = spawnSync("bun", ["--version"], {
23
+ cwd: packageRoot,
24
+ stdio: "ignore",
25
+ })
26
+ return result.status === 0
27
+ }
28
+
29
+ if (existsSync(publishedCliEntry) && hasBun()) {
30
+ runWithBun(publishedCliEntry)
31
+ } else if (existsSync(sourceCliEntry) && hasBun()) {
32
+ runWithBun(sourceCliEntry)
33
+ } else if (existsSync(publishedCliEntry)) {
34
+ await import(pathToFileURL(publishedCliEntry).href)
35
+ } else {
36
+ console.error("gorsee launcher could not find a runnable CLI entrypoint.")
37
+ process.exit(1)
38
+ }
@@ -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);
@@ -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";
@@ -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. */
@@ -65,6 +65,7 @@ export {
65
65
  createMemoryJobQueue,
66
66
  defineJob
67
67
  } from "./server/jobs.js";
68
+ export { createRedisJobQueue } from "./server/redis-job-queue.js";
68
69
  export {
69
70
  setupI18n,
70
71
  loadLocale,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gorsee",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "AI-first reactive full-stack TypeScript framework for deterministic human and agent collaboration",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",
@@ -21,7 +21,7 @@
21
21
  "web-framework"
22
22
  ],
23
23
  "bin": {
24
- "gorsee": "dist-pkg/bin/gorsee.js"
24
+ "gorsee": "bin/gorsee.js"
25
25
  },
26
26
  "exports": {
27
27
  ".": "./dist-pkg/index.js",
@@ -58,6 +58,7 @@
58
58
  "./deploy": "./dist-pkg/deploy/index.js"
59
59
  },
60
60
  "files": [
61
+ "bin/",
61
62
  "dist-pkg/",
62
63
  "README.md",
63
64
  "LICENSE"