akanjs 2.4.0-rc.9 → 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/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/server/akanApp.ts +10 -8
- package/server/artifact/builderRpc.ts +21 -5
- package/server/artifact/ipcTypes.ts +13 -1
- package/server/resolver/signal.resolver.ts +60 -12
- package/server/rscWorkerHost.ts +37 -2
- package/server/webRouter.ts +40 -3
- package/service/predefinedAdaptor/queue.adaptor.ts +20 -10
- package/service/predefinedAdaptor/solidQueue.adaptor.ts +3 -3
- package/signal/internalInfo.ts +1 -0
- package/types/server/artifact/builderRpc.d.ts +6 -0
- package/types/server/artifact/ipcTypes.d.ts +22 -1
- package/types/service/predefinedAdaptor/queue.adaptor.d.ts +3 -3
- package/types/service/predefinedAdaptor/solidQueue.adaptor.d.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# akanjs
|
|
2
2
|
|
|
3
|
+
## 2.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 23d43b3: Harden dev host recovery during failed builds:
|
|
8
|
+
|
|
9
|
+
- Defer builder/backend recycle while a generation's build is still failing
|
|
10
|
+
- Merge deferred invalidate batches so restarts cover every skipped change
|
|
11
|
+
- Recover the builder with exponential backoff instead of giving up
|
|
12
|
+
- Revive a backend that gave up once the build goes green again
|
|
13
|
+
- Resurrect dev children after a failed recycle so the error overlay stays reachable
|
|
14
|
+
- Enter degraded builder boot mode on compile errors and retry on the next edit
|
|
15
|
+
- Announce recovered pages/css state after a degraded boot succeeds
|
|
16
|
+
|
|
17
|
+
- 18abf71: Improve dev server stability:
|
|
18
|
+
|
|
19
|
+
- Add `isPortInUseError` utility for detecting EADDRINUSE across Bun versions
|
|
20
|
+
- Stop crash-looping replicas after max boot failures in dev mode (`akan start`)
|
|
21
|
+
- Handle parent IPC disconnect to prevent orphaned gateway/child processes
|
|
22
|
+
- Report `wsUpstream` in ready IPC so gateway routes to the actual bound port
|
|
23
|
+
- Fall back to ephemeral port when preferred WS port is in use
|
|
24
|
+
- Support controlled dev-host restart on config changes (`akan.config.ts`, `tsconfig`)
|
|
25
|
+
- Forward backend build-status IPC to dev host for error surfacing in HMR overlay
|
|
26
|
+
- Limit backend recovery attempts (5 max) and idle until next server-side edit
|
|
27
|
+
- Add integration tests for config-edit restart and boot-failure recovery
|
|
28
|
+
|
|
29
|
+
- 23d43b3: Improve the mobile Capacitor workflow:
|
|
30
|
+
|
|
31
|
+
- Auto-declare default Capacitor plugins in the app package.json before iOS/Android launch
|
|
32
|
+
- Expand mobile runtime peer dependencies and workspace-root preflight installs
|
|
33
|
+
- Derive repo-scoped default bundle ids to avoid Apple portal collisions
|
|
34
|
+
- Add `akan doctor --ios` to flag placeholder bundle identifiers
|
|
35
|
+
- Add `--device` to `akan start ios` for non-interactive simulator/device selection
|
|
36
|
+
- Prefer newer iOS runtimes and warn on SwiftUICore-incompatible simulators
|
|
37
|
+
- Detect SwiftUICore dyld failures with actionable guidance
|
|
38
|
+
- Select a routable LAN host for mobile live reload with override support
|
|
39
|
+
- Raise Android minSdkVersion to 26 for bundled Capacitor plugins
|
|
40
|
+
- Include `@capacitor-community/fcm` in push notification runtime packages
|
|
41
|
+
- Resolve client port from `window.location` on the browser client
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- d56a8f0: Ship Pretendard as the default font for newly created apps:
|
|
46
|
+
|
|
47
|
+
- Bundle Pretendard woff2 files under the app template `public/fonts`
|
|
48
|
+
- Declare `fonts` with `default: true` in the generated root `_layout.tsx`
|
|
49
|
+
|
|
3
50
|
## 2.3.11
|
|
4
51
|
|
|
5
52
|
### Minor Changes
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
64
|
-
else waiter.
|
|
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
|
|
101
|
-
if (
|
|
102
|
-
|
|
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
|
-
|
|
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;
|
package/server/rscWorkerHost.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/server/webRouter.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
480
|
-
if (!csrHtml) return
|
|
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<
|
|
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<
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
package/signal/internalInfo.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
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<
|
|
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<
|
|
17
|
+
runJob(job: AkanJob, handler: (job: AkanJob) => Promise<unknown>): Promise<void>;
|
|
18
18
|
}
|
|
19
19
|
export {};
|