akanjs 2.4.0 → 2.4.1-rc.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.4.0",
3
+ "version": "2.4.1-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -4,7 +4,7 @@ import { Logger } from "akanjs/common";
4
4
  import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport, AkanUpstream } from "akanjs/service";
5
5
  import { isTraceEnabled } from "akanjs/signal";
6
6
  import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
7
- import type { BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
7
+ import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
8
8
  import { isPortInUseError } from "./lifecycle/portInUse";
9
9
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
10
10
  import { ProcessMetricsCollector } from "./processMetricsCollector";
@@ -935,7 +935,8 @@ export class AkanApp {
935
935
  if (child && proc) void this.#scheduleChildRestart(child, proc, "child-error");
936
936
  return;
937
937
  case "build-route":
938
- this.#forwardBuildRoute(idx, message);
938
+ case "build-csr":
939
+ this.#forwardBuilderReq(idx, message);
939
940
  return;
940
941
  }
941
942
  }
@@ -951,7 +952,8 @@ export class AkanApp {
951
952
  this.#fanoutToFederation(message);
952
953
  return;
953
954
  case "build-route-res":
954
- this.#forwardBuildRouteResponse(message);
955
+ case "build-csr-res":
956
+ this.#forwardBuilderRes(message);
955
957
  return;
956
958
  }
957
959
  }
@@ -1134,22 +1136,22 @@ export class AkanApp {
1134
1136
  }
1135
1137
  }
1136
1138
 
1137
- #forwardBuildRoute(childIdx: number, message: BuilderReq) {
1139
+ #forwardBuilderReq(childIdx: number, message: BuilderReq | BuilderCsrReq) {
1138
1140
  const gatewayReqId = this.#nextBuilderReqId++;
1139
1141
  this.#builderReqMap.set(gatewayReqId, { childIdx, childLocalId: message.id });
1140
- process.send?.({ ...message, id: gatewayReqId } satisfies BuilderReq);
1142
+ process.send?.({ ...message, id: gatewayReqId } satisfies BuilderReq | BuilderCsrReq);
1141
1143
  }
1142
1144
 
1143
- #forwardBuildRouteResponse(message: BuilderRes) {
1145
+ #forwardBuilderRes(message: BuilderRes | BuilderCsrRes) {
1144
1146
  const request = this.#builderReqMap.get(message.id);
1145
1147
  if (!request) {
1146
- this.logger.warn(`No child found for build-route response id=${message.id}`);
1148
+ this.logger.warn(`No child found for ${message.type} id=${message.id}`);
1147
1149
  return;
1148
1150
  }
1149
1151
  this.#builderReqMap.delete(message.id);
1150
1152
  const child = this.#children.get(request.childIdx);
1151
1153
  if (!child || child.proc.killed) return;
1152
- this.#sendToChild(child, { ...message, id: request.childLocalId } satisfies BuilderRes);
1154
+ this.#sendToChild(child, { ...message, id: request.childLocalId } satisfies BuilderRes | BuilderCsrRes);
1153
1155
  }
1154
1156
 
1155
1157
  #fanoutToFederation(message: AkanIpcMessage | BuilderMessage, exceptIdx?: number) {
@@ -1,5 +1,7 @@
1
1
  import { Logger } from "akanjs/common";
2
2
  import type {
3
+ BuilderCsrReq,
4
+ BuilderCsrRes,
3
5
  BuilderEvent,
4
6
  BuilderMessage,
5
7
  BuilderReq,
@@ -45,7 +47,7 @@ export class BuilderRpc {
45
47
  readonly #pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
46
48
  readonly #offMessage: () => void;
47
49
  readonly #proc = process;
48
- readonly #send: (msg: BuilderReq) => void;
50
+ readonly #send: (msg: BuilderReq | BuilderCsrReq) => void;
49
51
  #nextId = 1;
50
52
  #disposed = false;
51
53
 
@@ -55,13 +57,13 @@ export class BuilderRpc {
55
57
  this.#send = this.#proc.send.bind(this.#proc);
56
58
  this.#offMessage = this.#listen((msg) => {
57
59
 
58
- if (msg.type === "build-route-res") {
59
- const res = msg as BuilderRes;
60
+ if (msg.type === "build-route-res" || msg.type === "build-csr-res") {
61
+ const res = msg as BuilderRes | BuilderCsrRes;
60
62
  const waiter = this.#pending.get(res.id);
61
63
  if (!waiter) return;
62
64
  this.#pending.delete(res.id);
63
- if (res.ok) waiter.resolve(res.data);
64
- else waiter.reject(new Error(`[builder] ${res.type} failed: ${res.error}`));
65
+ if (!res.ok) waiter.reject(new Error(`[builder] ${res.type} failed: ${res.error}`));
66
+ else waiter.resolve(res.type === "build-route-res" ? res.data : undefined);
65
67
  return;
66
68
  }
67
69
 
@@ -108,6 +110,20 @@ export class BuilderRpc {
108
110
  };
109
111
  }
110
112
 
113
+ /**
114
+ * Ask the builder to build the dev CSR artifact and keep it in sync from now on. The builder skips
115
+ * CSR by default (a full minified browser-target build of every page, ~350 MB per save), so the
116
+ * first `/__csr` or `?csr=true` request has nothing to serve until this resolves.
117
+ */
118
+ async buildCsr(reason: string): Promise<void> {
119
+ if (this.#disposed) throw new Error("[builder] rpc is disposed");
120
+ const id = this.#nextId++;
121
+ await new Promise<void>((resolve, reject) => {
122
+ this.#pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
123
+ this.#send({ type: "build-csr", id, reason });
124
+ });
125
+ }
126
+
111
127
  dispose(): void {
112
128
  if (this.#disposed) return;
113
129
  this.#disposed = true;
@@ -59,6 +59,18 @@ export type BuilderRes =
59
59
  | { type: "build-route-res"; id: number; ok: true; data: BuildRouteResultPayload }
60
60
  | { type: "build-route-res"; id: number; ok: false; error: string };
61
61
 
62
+ /**
63
+ * Dev CSR artifacts are only reachable through the opt-in `/__csr` and `?csr=true` routes (mobile
64
+ * local dev points a device WebView at the latter), so the builder skips them until a request
65
+ * actually needs one. The first such request arms the builder through this pair and waits for the
66
+ * build, after which every save keeps CSR in sync.
67
+ */
68
+ export type BuilderCsrReq = { type: "build-csr"; id: number; reason: string };
69
+
70
+ export type BuilderCsrRes =
71
+ | { type: "build-csr-res"; id: number; ok: true }
72
+ | { type: "build-csr-res"; id: number; ok: false; error: string };
73
+
62
74
  export interface PagesBundlePayload {
63
75
  bundlePath: string;
64
76
  buildId: number;
@@ -80,4 +92,4 @@ export type BuilderEvent =
80
92
  | { type: "pages-updated"; data: PagesBundlePayload }
81
93
  | { type: "build-status"; data: DevBuildStatus };
82
94
 
83
- export type BuilderMessage = BuilderReq | BuilderRes | BuilderEvent;
95
+ export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderEvent;
@@ -11,9 +11,10 @@ import {
11
11
  SLICE_META,
12
12
  } from "akanjs/base";
13
13
  import { capitalize, Logger } from "akanjs/common";
14
- import { serialize } from "akanjs/constant";
14
+ import { deserialize, serialize } from "akanjs/constant";
15
15
  import { documentQueryHelper } from "akanjs/document";
16
16
  import {
17
+ type AkanJob,
17
18
  type AkanJobOptions,
18
19
  type InjectRegistry,
19
20
  type LiveRegistry,
@@ -97,21 +98,22 @@ export class SignalResolver {
97
98
  static resolveSchedule(internalCls: InternalCls, internal: Internal, serverMode: "federation" | "batch" | "all") {
98
99
  const internalMeta = internalCls[INTERNAL_META] as { [key: string]: InternalInfo };
99
100
  Object.entries(internalMeta).forEach(([key, internalInfo]) => {
100
- const { enabled, operationMode, serverMode: targetServerMode } = internalInfo.signalOption;
101
- if (!enabled) return;
102
- else if (operationMode && !operationMode.includes(getEnv().operationMode)) return;
103
- else if (
104
- targetServerMode &&
105
- targetServerMode !== "all" &&
106
- serverMode !== "all" &&
107
- targetServerMode !== serverMode
108
- )
101
+ const skip = SignalResolver.#getScheduleSkipReason(internalInfo, serverMode);
102
+ if (skip) {
103
+ SignalResolver.#warnMissingProcessWorker(key, internalInfo, skip);
109
104
  return;
105
+ }
110
106
  switch (internalInfo.type) {
111
- case "process":
107
+ case "process": {
112
108
  if (!internalInfo.execFn) throw new Error(`Exec function is not set for ${key}`);
113
- internal.queue.registerProcessWorker(key, internalInfo.execFn.bind(internal));
109
+ const execFn = internalInfo.execFn.bind(internal);
110
+
111
+ internal.queue.registerProcessWorker(
112
+ key,
113
+ async (job) => await execFn(...SignalResolver.#getJobArgs(key, internalInfo, job), job),
114
+ );
114
115
  break;
116
+ }
115
117
  case "init":
116
118
  internal.schedule.registerInit(key, () => internalInfo.execFn?.bind(internal)());
117
119
  break;
@@ -150,6 +152,52 @@ export class SignalResolver {
150
152
  }
151
153
  });
152
154
  }
155
+ /** Why an internal is not scheduled on this server, or null when it is. `placement` marks a deliberate role split. */
156
+ static #getScheduleSkipReason(
157
+ internalInfo: InternalInfo,
158
+ serverMode: "federation" | "batch" | "all",
159
+ ): { reason: string; placement: boolean } | null {
160
+ const { enabled, operationMode, serverMode: targetServerMode } = internalInfo.signalOption;
161
+ if (!enabled) return { reason: "the internal is disabled (`enabled: false`)", placement: false };
162
+ if (operationMode && !operationMode.includes(getEnv().operationMode))
163
+ return {
164
+ reason: `operationMode "${getEnv().operationMode}" is not in [${operationMode.join(", ")}]`,
165
+ placement: true,
166
+ };
167
+ if (targetServerMode && targetServerMode !== "all" && serverMode !== "all" && targetServerMode !== serverMode)
168
+ return {
169
+ reason: `serverMode is "${serverMode}" but the internal declares "${targetServerMode}"`,
170
+ placement: true,
171
+ };
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * A `process` producer is installed on every server regardless of placement, so a skipped worker means this
177
+ * server can enqueue jobs that nothing here consumes. Surface that asymmetry instead of failing silently.
178
+ */
179
+ static #warnMissingProcessWorker(
180
+ key: string,
181
+ internalInfo: InternalInfo,
182
+ { reason, placement }: { reason: string; placement: boolean },
183
+ ) {
184
+ if (internalInfo.type !== "process") return;
185
+ const message = `No worker registered for process internal "${key}" because ${reason}. Jobs enqueued here stay pending unless another server consumes them.`;
186
+ if (placement) SignalResolver.logger.verbose(message);
187
+ else SignalResolver.logger.warn(message);
188
+ }
189
+
190
+ /** Maps a job payload back onto the internal's declared `msg` args, deserializing each to its declared type. */
191
+ static #getJobArgs(key: string, internalInfo: InternalInfo, job: AkanJob): unknown[] {
192
+ const data = Array.isArray(job.data) ? (job.data as unknown[]) : job.data === undefined ? [] : [job.data];
193
+ return internalInfo.args.map((arg, idx) =>
194
+ deserialize(arg.argRef, arg.arrDepth, data[idx], {
195
+ key: `${key}.${arg.name}`,
196
+ nullable: arg.option?.nullable,
197
+ }),
198
+ );
199
+ }
200
+
153
201
  static resolveSlice(sliceCls: SliceCls): EndpointCls {
154
202
  const sliceMeta = sliceCls[SLICE_META] as { [key: string]: SliceInfo };
155
203
  const cnst = sliceCls.srv.cnst;
@@ -309,6 +309,8 @@ export interface RscWorkerOptions {
309
309
  type WorkerStatus = "starting" | "ready" | "restarting" | "stopped";
310
310
 
311
311
  export class RscWorker {
312
+ static readonly #devMaxReloads = 10;
313
+ static readonly #devMaxRssBytes = 768 * 1024 * 1024;
312
314
  readonly ready: Promise<void>;
313
315
  #logger = new Logger("RscWorker");
314
316
 
@@ -332,6 +334,8 @@ export class RscWorker {
332
334
  #restartAttempts = 0;
333
335
  #restartCount = 0;
334
336
  #recycleCount = 0;
337
+ #reloadsSinceSpawn = 0;
338
+ #lastRecycleAtMono: number | null = null;
335
339
  #lastRecycleReason: string | undefined;
336
340
  #lastWorkerMetrics: AkanMetricsReport = {};
337
341
  #hostPendingChunkOverflowCount = 0;
@@ -498,6 +502,7 @@ export class RscWorker {
498
502
  }
499
503
  this.#lastRecycleReason = reason;
500
504
  this.#recycleCount += 1;
505
+ this.#lastRecycleAtMono = performance.now();
501
506
  const oldPid = this.#proc.pid;
502
507
  this.#logger.info(`[rsc] rolling recycle worker reason=${reason} oldPid=${oldPid}`);
503
508
  this.#status = "restarting";
@@ -535,6 +540,11 @@ export class RscWorker {
535
540
  if (input.pagesBundlePath) this.#pagesBundlePath = input.pagesBundlePath;
536
541
 
537
542
  if (this.#status !== "ready") return Promise.resolve();
543
+
544
+ if (this.#shouldRecycleForReloadAccumulation() && this.restartWhenIdle("pages-reload-accumulation")) {
545
+ return Promise.resolve();
546
+ }
547
+ this.#reloadsSinceSpawn += 1;
538
548
  return new Promise<void>((resolve, reject) => {
539
549
 
540
550
  if (this.#pendingReload) this.#pendingReload.resolve();
@@ -556,6 +566,7 @@ export class RscWorker {
556
566
 
557
567
  #spawn(): Bun.Subprocess<"ignore", "inherit", "inherit"> {
558
568
  this.#status = "starting";
569
+ this.#reloadsSinceSpawn = 0;
559
570
  const workerPath = this.#resolveWorkerPath();
560
571
  let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
561
572
  const earlyMessages: RscInMsg[] = [];
@@ -786,6 +797,15 @@ export class RscWorker {
786
797
  }, delay);
787
798
  }
788
799
 
800
+ #shouldRecycleForReloadAccumulation(): boolean {
801
+ const maxReloads = RscWorker.#getRscMaxReloads();
802
+ if (!maxReloads || this.#reloadsSinceSpawn < maxReloads) return false;
803
+
804
+ const sinceLastRecycleMs = this.#lastRecycleAtMono === null ? null : performance.now() - this.#lastRecycleAtMono;
805
+ if (sinceLastRecycleMs !== null && sinceLastRecycleMs < RscWorker.#getRscMinRecycleIntervalMs()) return false;
806
+ return true;
807
+ }
808
+
789
809
  #maybeRecycleFromMetrics(metrics: AkanMetricsReport): void {
790
810
  if (this.#pending.size > 0) return;
791
811
  const maxRssBytes = RscWorker.#getRscMaxRssBytes();
@@ -846,6 +866,20 @@ export class RscWorker {
846
866
  return RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_RECYCLE_GRACE_MS") ?? 5_000;
847
867
  }
848
868
 
869
+ /**
870
+ * Reloads tolerated before the worker is recycled instead of reloaded in place. Production imports
871
+ * the pages bundle once at boot and never reloads, so the threshold only applies to dev.
872
+ */
873
+ static #getRscMaxReloads(): number | null {
874
+ if (process.env.AKAN_RSC_WORKER_MAX_RELOADS !== undefined)
875
+ return RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RELOADS");
876
+ return RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxReloads;
877
+ }
878
+
879
+ static #getRscMinRecycleIntervalMs(): number {
880
+ return RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS") ?? 1_000;
881
+ }
882
+
849
883
  static #getRscMaxRssBytes(): number | null {
850
884
  const explicitMb = RscWorker.#parsePositiveIntEnv("AKAN_RSC_WORKER_MAX_RSS_MB");
851
885
  if (explicitMb) return explicitMb * 1024 * 1024;
@@ -853,9 +887,10 @@ export class RscWorker {
853
887
  const explicitBytes = RscWorker.#parseBytesEnv("AKAN_RSC_WORKER_MAX_RSS");
854
888
  if (explicitBytes) return explicitBytes;
855
889
 
856
- if (!RscWorker.#isProductionRuntime()) return null;
857
890
  const memoryLimitBytes = RscWorker.#parseBytesEnv("AKAN_MEMORY_LIMIT") ?? RscWorker.#readCgroupMemoryLimitBytes();
858
- return memoryLimitBytes ? Math.floor(memoryLimitBytes * 0.55) : null;
891
+ if (memoryLimitBytes) return Math.floor(memoryLimitBytes * 0.55);
892
+
893
+ return RscWorker.#isProductionRuntime() ? null : RscWorker.#devMaxRssBytes;
859
894
  }
860
895
 
861
896
  static #getRscMaxRouteModules(): number | null {
@@ -273,6 +273,8 @@ export class WebRouter {
273
273
  #builderRpc: BuilderRpc | null;
274
274
  #routeCache: RouteClientCache;
275
275
  #devHmr: DevHmrController | null = null;
276
+ #csrArmed = false;
277
+ #csrOnDemandBuild: Promise<unknown> | null = null;
276
278
  readonly #requestStats = {
277
279
  fullSsr: 0,
278
280
  rscNavigation: 0,
@@ -340,7 +342,7 @@ export class WebRouter {
340
342
  const renderEnvRoutes: HttpRoutes = {
341
343
  "/__csr": async () => {
342
344
  this.#requestStats.csr += 1;
343
- const csrHtml = WebRouter.#resolveCsrHtmlPath(csrOutputDir, "/", this.#artifact);
345
+ const csrHtml = await this.#resolveCsrHtml(csrOutputDir, "/");
344
346
  const csrFile = csrHtml ? Bun.file(csrHtml) : null;
345
347
  const htmlText =
346
348
  csrFile && (await csrFile.exists())
@@ -476,8 +478,8 @@ export class WebRouter {
476
478
  const isCsr = url.searchParams.get("csr") === "true";
477
479
  if (isCsr) {
478
480
  this.#requestStats.csr += 1;
479
- const csrHtml = WebRouter.#resolveCsrHtmlPath(csrOutputDir, url.pathname, this.#artifact);
480
- if (!csrHtml) return new Response("Not Found", { status: 404 });
481
+ const csrHtml = await this.#resolveCsrHtml(csrOutputDir, url.pathname);
482
+ if (!csrHtml) return this.#csrUnavailableResponse(url.pathname);
481
483
  const html = await Bun.file(csrHtml).text();
482
484
  return new Response(this.#withCsrHmr(html), {
483
485
  headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -884,6 +886,41 @@ export class WebRouter {
884
886
  if (this.#prodMode) return html;
885
887
  return WebRouter.#injectBeforeBodyEnd(html, `<script>${HMR_CLIENT_SCRIPT}</script>`);
886
888
  }
889
+ /**
890
+ * Resolve a CSR html file, asking the builder to build the artifact first if it is not there yet.
891
+ * Dev CSR is only reachable through `/__csr` and `?csr=true`, so the builder skips it until one of
892
+ * them is requested; the first request pays for the build and every save keeps it in sync after.
893
+ */
894
+ async #resolveCsrHtml(csrOutputDir: string, pathname: string): Promise<string | null> {
895
+ const resolved = WebRouter.#resolveCsrHtmlPath(csrOutputDir, pathname, this.#artifact);
896
+ if (resolved) return resolved;
897
+ await this.#armCsrArtifact(pathname);
898
+ return WebRouter.#resolveCsrHtmlPath(csrOutputDir, pathname, this.#artifact);
899
+ }
900
+ #armCsrArtifact(reason: string): Promise<unknown> {
901
+ const rpc = this.#builderRpc;
902
+
903
+ if (this.#csrArmed || !rpc) return Promise.resolve();
904
+ this.#csrOnDemandBuild ??= rpc
905
+ .buildCsr(reason)
906
+ .then(() => {
907
+ this.#csrArmed = true;
908
+ this.#logger.info(`[csr] dev CSR artifact built on demand (${reason})`);
909
+ })
910
+ .catch((err: unknown) => {
911
+ this.#logger.error(`[csr] on-demand build failed: ${err instanceof Error ? err.message : String(err)}`);
912
+ })
913
+ .finally(() => {
914
+ this.#csrOnDemandBuild = null;
915
+ });
916
+ return this.#csrOnDemandBuild;
917
+ }
918
+ #csrUnavailableResponse(pathname: string): Response {
919
+ if (this.#prodMode) return new Response("Not Found", { status: 404 });
920
+ const message = `No CSR artifact for ${pathname}. Dev CSR is built on demand; check the dev server log for a csr-build failure and verify the basePath in the URL.`;
921
+ this.#logger.warn(`[csr] ${message}`);
922
+ return new Response(message, { status: 404, headers: { "Content-Type": "text/plain; charset=utf-8" } });
923
+ }
887
924
  static #injectBeforeBodyEnd(html: string, snippet: string): string {
888
925
  const matches = [...html.matchAll(/<\/body\s*>/gi)];
889
926
  const last = matches.at(-1);
@@ -9,7 +9,7 @@ type QueueLike = {
9
9
  type WorkerLike = AkanWorker;
10
10
  type WorkerConstructor = new (
11
11
  name: string,
12
- handler: (job: AkanJob) => Promise<void>,
12
+ handler: (job: AkanJob) => Promise<unknown>,
13
13
  options: { connection: unknown },
14
14
  ) => WorkerLike;
15
15
  type QueueConstructor = new (
@@ -23,7 +23,7 @@ type BullmqModule = {
23
23
  const bullmqPackage = "bullmq";
24
24
 
25
25
  export interface QueueAdaptor {
26
- registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<void>): AkanWorker;
26
+ registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<unknown>): AkanWorker;
27
27
  registerProcessQueue(key: string, args: unknown[], jobOptions?: AkanJobOptions): Promise<AkanJob>;
28
28
  }
29
29
 
@@ -34,28 +34,38 @@ export class BullQueue
34
34
  }))
35
35
  implements QueueAdaptor
36
36
  {
37
- #queue!: QueueLike;
37
+ #queues = new Map<string, QueueLike>();
38
+ #Queue!: QueueConstructor;
38
39
  #Worker!: WorkerConstructor;
39
40
 
40
41
  override async onInit(): Promise<void> {
41
42
  const { Queue, Worker } = (await import(bullmqPackage)) as BullmqModule;
43
+ this.#Queue = Queue;
42
44
  this.#Worker = Worker;
43
- this.#queue = new Queue(this.prefix, {
45
+ }
46
+
47
+ #queueName(key: string) {
48
+ return `${this.prefix}:${key}`;
49
+ }
50
+ getQueue(key: string): QueueLike {
51
+ const name = this.#queueName(key);
52
+ const queue = this.#queues.get(name);
53
+ if (queue) return queue;
54
+ const newQueue = new this.#Queue(name, {
44
55
  connection: this.redis,
45
56
  defaultJobOptions: { removeOnComplete: true, removeOnFail: true },
46
57
  });
58
+ this.#queues.set(name, newQueue);
59
+ return newQueue;
47
60
  }
48
- getQueue(): QueueLike {
49
- return this.#queue;
50
- }
51
- registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<void>): WorkerLike {
52
- const worker = new this.#Worker(`${this.prefix}:${key}`, handler, {
61
+ registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<unknown>): WorkerLike {
62
+ const worker = new this.#Worker(this.#queueName(key), handler, {
53
63
  connection: this.redis,
54
64
  });
55
65
  return worker;
56
66
  }
57
67
  async registerProcessQueue(key: string, args: unknown[], jobOptions?: AkanJobOptions): Promise<AkanJob> {
58
- const job = await this.#queue.add(`${this.prefix}:${key}`, args, jobOptions);
68
+ const job = await this.getQueue(key).add(key, args, jobOptions);
59
69
  return job as unknown as AkanJob;
60
70
  }
61
71
  }
@@ -21,7 +21,7 @@ class SolidWorker implements AkanWorker {
21
21
  constructor(
22
22
  private readonly queue: SolidQueue,
23
23
  private readonly name: string,
24
- private readonly handler: (job: AkanJob) => Promise<void>,
24
+ private readonly handler: (job: AkanJob) => Promise<unknown>,
25
25
  pollIntervalMs: number,
26
26
  ) {
27
27
  this.#timer = setInterval(() => void this.pump(), pollIntervalMs);
@@ -116,7 +116,7 @@ export class SolidQueue
116
116
  this.#db?.close();
117
117
  }
118
118
 
119
- registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<void>): AkanWorker {
119
+ registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<unknown>): AkanWorker {
120
120
  const worker = new SolidWorker(this, key, handler, this.config.queuePollIntervalMs);
121
121
  this.#workers.set(key, worker);
122
122
  return worker;
@@ -169,7 +169,7 @@ export class SolidQueue
169
169
  }
170
170
  }
171
171
 
172
- async runJob(job: AkanJob, handler: (job: AkanJob) => Promise<void>) {
172
+ async runJob(job: AkanJob, handler: (job: AkanJob) => Promise<unknown>) {
173
173
  try {
174
174
  await handler({ ...job, data: job.data });
175
175
  this.#db
@@ -255,6 +255,7 @@ export const buildInternal = {
255
255
  signalOption?: SignalOption<Returns, Nullable>,
256
256
  ) =>
257
257
  new InternalInfo("process", returnRef, {
258
+ enabled: true,
258
259
  serverMode: "all",
259
260
  ...signalOption,
260
261
  }),
@@ -40,5 +40,11 @@ export declare class BuilderRpc {
40
40
  knownEntries: Set<string>;
41
41
  generation?: number;
42
42
  }): Promise<BuildRouteClientResult>;
43
+ /**
44
+ * Ask the builder to build the dev CSR artifact and keep it in sync from now on. The builder skips
45
+ * CSR by default (a full minified browser-target build of every page, ~350 MB per save), so the
46
+ * first `/__csr` or `?csr=true` request has nothing to serve until this resolves.
47
+ */
48
+ buildCsr(reason: string): Promise<void>;
43
49
  dispose(): void;
44
50
  }
@@ -64,6 +64,27 @@ export type BuilderRes = {
64
64
  ok: false;
65
65
  error: string;
66
66
  };
67
+ /**
68
+ * Dev CSR artifacts are only reachable through the opt-in `/__csr` and `?csr=true` routes (mobile
69
+ * local dev points a device WebView at the latter), so the builder skips them until a request
70
+ * actually needs one. The first such request arms the builder through this pair and waits for the
71
+ * build, after which every save keeps CSR in sync.
72
+ */
73
+ export type BuilderCsrReq = {
74
+ type: "build-csr";
75
+ id: number;
76
+ reason: string;
77
+ };
78
+ export type BuilderCsrRes = {
79
+ type: "build-csr-res";
80
+ id: number;
81
+ ok: true;
82
+ } | {
83
+ type: "build-csr-res";
84
+ id: number;
85
+ ok: false;
86
+ error: string;
87
+ };
67
88
  export interface PagesBundlePayload {
68
89
  bundlePath: string;
69
90
  buildId: number;
@@ -92,4 +113,4 @@ export type BuilderEvent = {
92
113
  type: "build-status";
93
114
  data: DevBuildStatus;
94
115
  };
95
- export type BuilderMessage = BuilderReq | BuilderRes | BuilderEvent;
116
+ export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderEvent;
@@ -5,7 +5,7 @@ type QueueLike = {
5
5
  };
6
6
  type WorkerLike = AkanWorker;
7
7
  export interface QueueAdaptor {
8
- registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<void>): AkanWorker;
8
+ registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<unknown>): AkanWorker;
9
9
  registerProcessQueue(key: string, args: unknown[], jobOptions?: AkanJobOptions): Promise<AkanJob>;
10
10
  }
11
11
  declare const BullQueue_base: import("..").AdaptorCls<{}, {
@@ -15,8 +15,8 @@ declare const BullQueue_base: import("..").AdaptorCls<{}, {
15
15
  export declare class BullQueue extends BullQueue_base implements QueueAdaptor {
16
16
  #private;
17
17
  onInit(): Promise<void>;
18
- getQueue(): QueueLike;
19
- registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<void>): WorkerLike;
18
+ getQueue(key: string): QueueLike;
19
+ registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<unknown>): WorkerLike;
20
20
  registerProcessQueue(key: string, args: unknown[], jobOptions?: AkanJobOptions): Promise<AkanJob>;
21
21
  }
22
22
  export {};
@@ -10,10 +10,10 @@ export declare class SolidQueue extends SolidQueue_base implements QueueAdaptor
10
10
  #private;
11
11
  onInit(): Promise<void>;
12
12
  onDestroy(): Promise<void>;
13
- registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<void>): AkanWorker;
13
+ registerProcessWorker(key: string, handler: (job: AkanJob) => Promise<unknown>): AkanWorker;
14
14
  registerProcessQueue(key: string, args: unknown[], jobOptions?: AkanJobOptions): Promise<AkanJob>;
15
15
  wake(name?: string): void;
16
16
  claimJob(name: string): AkanJob | null;
17
- runJob(job: AkanJob, handler: (job: AkanJob) => Promise<void>): Promise<void>;
17
+ runJob(job: AkanJob, handler: (job: AkanJob) => Promise<unknown>): Promise<void>;
18
18
  }
19
19
  export {};