akanjs 2.3.5 → 2.3.6-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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # akanjs
2
2
 
3
+ ## 2.3.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix Akan document and service type regressions for extended constant, document, signal, and store models.
8
+
3
9
  ## 2.3.2
4
10
 
5
11
  ### Minor Changes
package/README.ko.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Akan.js
2
2
 
3
- [English](https://unpkg.com/akanjs@latest/README.md) | [Docs](https://akanjs.com/docs) | [npm](https://www.npmjs.com/package/akanjs)
3
+ [English](./README.md) | [Docs](https://akanjs.com/docs) | [npm](https://www.npmjs.com/package/akanjs)
4
4
 
5
5
  **한 줄의 비즈니스 코드로 웹·iOS·Android·서버·DB를 한 번에.**
6
6
  **One line of business code ships web, iOS, Android, server, and database together.**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Akan.js
2
2
 
3
- [한국어](https://unpkg.com/akanjs@latest/README.ko.md) | [Docs](https://akanjs.com/docs) | [npm](https://www.npmjs.com/package/akanjs)
3
+ [한국어](./README.ko.md) | [Docs](https://akanjs.com/docs) | [npm](https://www.npmjs.com/package/akanjs)
4
4
 
5
5
  **One line of business code ships web, iOS, Android, server, and database together.**
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.3.5",
3
+ "version": "2.3.6-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -46,6 +46,7 @@ export interface AkanAppOptions {
46
46
  runtimeDir?: string;
47
47
  port?: number;
48
48
  wsBasePort?: number;
49
+ openapi?: boolean;
49
50
  }
50
51
 
51
52
  interface AkanReplicaConfig {
@@ -70,6 +71,7 @@ export class AkanApp {
70
71
  readonly #socketRunId = `${process.pid}-${Date.now().toString(36)}`;
71
72
  readonly #port: number;
72
73
  readonly #wsBasePort: number;
74
+ readonly #openapi?: boolean;
73
75
  readonly #children = new Map<number, ChildState>();
74
76
  readonly #roomChildren = new Map<string, Set<number>>();
75
77
  readonly #childRooms = new Map<number, Set<string>>();
@@ -97,15 +99,17 @@ export class AkanApp {
97
99
  #stopping = false;
98
100
 
99
101
  constructor(serverPath = "./server", options: AkanAppOptions = {}) {
100
- this.#serverPath = AkanApp.#resolveServerPath(options.serverPath ?? serverPath);
102
+ const resolvedOptions = options;
103
+ this.#serverPath = AkanApp.#resolveServerPath(resolvedOptions.serverPath ?? serverPath);
101
104
  this.#artifactDir = path.resolve(path.dirname(this.#serverPath), ".akan", "artifact");
102
- this.#replica = AkanApp.#parseReplicaConfig(options.replica);
105
+ this.#replica = AkanApp.#parseReplicaConfig(resolvedOptions.replica);
103
106
  this.#runtimeDir =
104
- (options.runtimeDir ?? process.env.AKAN_RUNTIME_DIR ?? process.env.NODE_ENV === "production")
107
+ (resolvedOptions.runtimeDir ?? process.env.AKAN_RUNTIME_DIR ?? process.env.NODE_ENV === "production")
105
108
  ? path.resolve(process.cwd(), "runtime")
106
109
  : path.resolve(process.cwd(), "local", "apps", process.env.AKAN_PUBLIC_APP_NAME ?? "unknown", "runtime");
107
- this.#port = Number(options.port ?? process.env.PORT ?? 8282);
108
- this.#wsBasePort = Number(options.wsBasePort ?? process.env.AKAN_WS_BASE_PORT ?? this.#port + 10_000);
110
+ this.#port = Number(resolvedOptions.port ?? process.env.PORT ?? 8282);
111
+ this.#wsBasePort = Number(resolvedOptions.wsBasePort ?? process.env.AKAN_WS_BASE_PORT ?? this.#port + 10_000);
112
+ this.#openapi = resolvedOptions.openapi;
109
113
  }
110
114
 
111
115
  static #resolveServerPath(serverPath: string) {
@@ -225,6 +229,7 @@ export class AkanApp {
225
229
  SERVER_MODE: role,
226
230
  AKAN_CHILD_SOCKET: upstream.http.socketPath,
227
231
  AKAN_CHILD_WS_PORT: upstream.ws ? String(upstream.ws.port) : "",
232
+ ...(this.#openapi === undefined ? {} : { AKAN_OPENAPI: this.#openapi ? "true" : "false" }),
228
233
  },
229
234
  ipc: (message) => this.#handleMessage(idx, message as AkanIpcMessage, proc),
230
235
  stdout: "pipe",
@@ -777,6 +782,7 @@ export class AkanApp {
777
782
  case "invalidate":
778
783
  case "css-updated":
779
784
  case "pages-updated":
785
+ case "build-status":
780
786
  this.#fanoutToFederation(message);
781
787
  return;
782
788
  case "build-route-res":
@@ -10,6 +10,8 @@ import type {
10
10
  SolidConfig,
11
11
  } from "akanjs/service";
12
12
  import type { ServerSignal, ServerSignalCls, WebsocketPublishData } from "akanjs/signal";
13
+ import { createOpenApiDocument } from "../signal/openapi";
14
+ import { FetchSerializer } from "../signal/serializer";
13
15
  import type { AkanLib, AkanLibProps } from "./akanLib";
14
16
  import type { BuilderRpc } from "./artifact";
15
17
  import { DiLifecycle } from "./di/diLifecycle";
@@ -26,12 +28,18 @@ export interface AkanServerProps extends AkanLibProps {
26
28
  env?: BaseEnv;
27
29
  prefix?: string;
28
30
  websocketPrefix?: string;
31
+ openapi?: boolean;
32
+ }
33
+
34
+ export interface AkanServerOptions {
35
+ openapi?: boolean;
29
36
  }
30
37
 
31
38
  interface AkanAppPrepared {
32
39
  routes: SignalRoutes["routes"];
33
40
  routeOptions: SignalRoutes["routeOptions"];
34
41
  wsRoutes: WebsocketRoutes;
42
+ builtinRoutes: HttpRoutes;
35
43
  renderEnvRoutes: HttpRoutes;
36
44
  hmrHub: HmrWsHub | null;
37
45
  builderRpc: BuilderRpc | null;
@@ -64,6 +72,7 @@ export class AkanServer {
64
72
  readonly env: BaseEnv & { database?: DatabaseConfig; solid?: SolidConfig };
65
73
  prefix = "/api";
66
74
  websocketPrefix = "/ws";
75
+ openapi = AkanServer.#isOpenApiEnvEnabled();
67
76
  serverMode: "federation" | "batch" | "all";
68
77
  shutdownTimeoutMs = 30_000;
69
78
 
@@ -78,12 +87,14 @@ export class AkanServer {
78
87
  | "batch"
79
88
  | "all"
80
89
  | undefined) ?? "all",
81
- ...libs: AkanLib[]
90
+ ...libsOrOptions: (AkanLib | AkanServerOptions)[]
82
91
  ) {
92
+ const { libs, options } = AkanServer.#splitLibsAndOptions(libsOrOptions);
83
93
  this.name = name;
84
94
  this.logger = new Logger(name);
85
95
  this.libs = libs;
86
96
  this.env = { ...env };
97
+ this.openapi = options?.openapi ?? this.openapi;
87
98
  this.serverMode = serverMode;
88
99
  this.#di = new DiLifecycle(this.env, serverMode, ...libs);
89
100
  }
@@ -95,6 +106,11 @@ export class AkanServer {
95
106
  this.websocketPrefix = websocketPrefix;
96
107
  return this;
97
108
  }
109
+ setOpenApi(openapi = true) {
110
+ if (this.status !== "stopped") throw new Error("OpenAPI config must be set before app initialization.");
111
+ this.openapi = openapi;
112
+ return this;
113
+ }
98
114
  setDatabaseConfig(database: DatabaseConfig) {
99
115
  if (this.status !== "stopped") throw new Error("Database config must be set before app initialization.");
100
116
  this.env.database = database;
@@ -170,6 +186,7 @@ export class AkanServer {
170
186
  routes,
171
187
  routeOptions,
172
188
  wsRoutes,
189
+ builtinRoutes: this.#createBuiltinRoutes(),
173
190
  renderEnvRoutes: {},
174
191
  hmrHub: null,
175
192
  builderRpc: null,
@@ -185,7 +202,17 @@ export class AkanServer {
185
202
  });
186
203
  const { renderEnvRoutes, hmrHub, builderRpc } = await webRouter.initializeRoute();
187
204
  const webProxyRunner = WebProxyRunner.create(this.#di.webProxies);
188
- this.#prepared = { routes, routeOptions, wsRoutes, renderEnvRoutes, hmrHub, builderRpc, webRouter, webProxyRunner };
205
+ this.#prepared = {
206
+ routes,
207
+ routeOptions,
208
+ wsRoutes,
209
+ builtinRoutes: this.#createBuiltinRoutes(),
210
+ renderEnvRoutes,
211
+ hmrHub,
212
+ builderRpc,
213
+ webRouter,
214
+ webProxyRunner,
215
+ };
189
216
  this.status = "initialized";
190
217
  return this;
191
218
  }
@@ -200,7 +227,8 @@ export class AkanServer {
200
227
  : Number(process.env.AKAN_CHILD_WS_PORT || process.env.PORT || 8282);
201
228
  const unix = process.env.AKAN_CHILD_SOCKET || undefined;
202
229
  this.logger.verbose(`${this.name} is serving on ${unix ? `unix://${unix}` : `port ${port}`}`);
203
- const { routes, routeOptions, wsRoutes, renderEnvRoutes, hmrHub, webRouter, webProxyRunner } = this.#prepared;
230
+ const { routes, routeOptions, wsRoutes, builtinRoutes, renderEnvRoutes, hmrHub, webRouter, webProxyRunner } =
231
+ this.#prepared;
204
232
  const websocketHandlers = {
205
233
  ...ApiRouter.buildWebsocketHandlers({
206
234
  wsRoutes,
@@ -220,6 +248,7 @@ export class AkanServer {
220
248
  prefix: this.prefix,
221
249
  websocketPrefix: this.websocketPrefix,
222
250
  routes,
251
+ builtinRoutes,
223
252
  routeOptions,
224
253
  renderEnvRoutes,
225
254
  upgradeAppWs: (req, data) => this.#server?.upgrade(req, { data }) ?? false,
@@ -236,6 +265,7 @@ export class AkanServer {
236
265
  prefix: this.prefix,
237
266
  websocketPrefix: this.websocketPrefix,
238
267
  routes,
268
+ builtinRoutes,
239
269
  routeOptions,
240
270
  renderEnvRoutes,
241
271
  upgradeAppWs: (req, data) => this.#wsServer?.upgrade(req, { data }) ?? false,
@@ -390,6 +420,47 @@ export class AkanServer {
390
420
  );
391
421
  }
392
422
 
423
+ #createBuiltinRoutes(): HttpRoutes {
424
+ if (!this.openapi) return {};
425
+ return {
426
+ "/openapi.json": {
427
+ GET: () =>
428
+ Response.json(
429
+ createOpenApiDocument(FetchSerializer.serializeRegistry(this.#di.live).signal, {
430
+ title: `${this.env.appName} API`,
431
+ version: "0.0.0",
432
+ servers: this.#getOpenApiServers(),
433
+ }),
434
+ ),
435
+ },
436
+ };
437
+ }
438
+
439
+ #getOpenApiServers() {
440
+ const serverHttpUri = (this.env as { serverHttpUri?: string }).serverHttpUri;
441
+ if (!serverHttpUri) return undefined;
442
+ return [{ url: serverHttpUri.replace(/\/api\/?$/, "") }];
443
+ }
444
+
445
+ static #splitLibsAndOptions(libsOrOptions: (AkanLib | AkanServerOptions)[]) {
446
+ const last = libsOrOptions.at(-1);
447
+ const options = AkanServer.#isServerOptions(last) ? last : undefined;
448
+ const libs = (options ? libsOrOptions.slice(0, -1) : libsOrOptions) as AkanLib[];
449
+ return { libs, options };
450
+ }
451
+
452
+ static #isServerOptions(value: AkanLib | AkanServerOptions | undefined): value is AkanServerOptions {
453
+ return Boolean(
454
+ value && !("database" in value) && !("service" in value) && !("scalar" in value) && "openapi" in value,
455
+ );
456
+ }
457
+
458
+ static #isOpenApiEnvEnabled() {
459
+ return [process.env.AKAN_OPENAPI, process.env.AKAN_PUBLIC_OPENAPI].some(
460
+ (value) => value === "true" || value === "1",
461
+ );
462
+ }
463
+
393
464
  async #withShutdownTimeout<T>(promise: Promise<T>) {
394
465
  let timeout: Timer | null = null;
395
466
  try {
@@ -6,6 +6,7 @@ import type {
6
6
  BuilderRes,
7
7
  BuildRouteResultPayload,
8
8
  CssPayload,
9
+ DevBuildStatus,
9
10
  PagesBundlePayload,
10
11
  } from "./ipcTypes";
11
12
  import type { BuildRouteClientResult } from "./manifestTypes";
@@ -26,6 +27,8 @@ export interface BuilderRpcEventHandlers {
26
27
  * worker subprocess.
27
28
  */
28
29
  onPagesUpdated?: (bundle: PagesBundlePayload) => void;
30
+ /** Builder reported a dev build phase success/failure. */
31
+ onBuildStatus?: (status: DevBuildStatus) => void;
29
32
  }
30
33
 
31
34
  /**
@@ -76,6 +79,9 @@ export class BuilderRpc {
76
79
  case "pages-updated":
77
80
  handlers.onPagesUpdated?.(ev.data);
78
81
  return;
82
+ case "build-status":
83
+ handlers.onBuildStatus?.(ev.data);
84
+ return;
79
85
  default:
80
86
  return;
81
87
  }
@@ -16,6 +16,35 @@ export interface CssPayload {
16
16
  changedFiles?: string[];
17
17
  }
18
18
 
19
+ export type DevChangeRole = "server" | "client" | "shared" | "barrel" | "config" | "css";
20
+
21
+ export type DevChangeAction =
22
+ | "restart-backend"
23
+ | "rebuild-client"
24
+ | "rebuild-css"
25
+ | "sync-generated"
26
+ | "restart-dev-host"
27
+ | "report-error";
28
+
29
+ export interface DevChangePlan {
30
+ generation: number;
31
+ files: string[];
32
+ generatedFiles: string[];
33
+ roles: DevChangeRole[];
34
+ actions: DevChangeAction[];
35
+ reasonByFile: Record<string, string[]>;
36
+ }
37
+
38
+ export type BuildPhase = "scan" | "barrel" | "csr" | "pages" | "css" | "route" | "backend";
39
+
40
+ export interface DevBuildStatus {
41
+ generation: number;
42
+ phase: BuildPhase;
43
+ ok: boolean;
44
+ files: string[];
45
+ message?: string;
46
+ }
47
+
19
48
  export type BuilderReq = {
20
49
  type: "build-route";
21
50
  id: number;
@@ -39,8 +68,15 @@ export interface PagesBundlePayload {
39
68
  export type BuilderEvent =
40
69
  | { type: "builder-ready"; buildId: string }
41
70
  | { type: "backend-ready"; pid: number }
42
- | { type: "invalidate"; kinds: ("code" | "css" | "config")[]; files: string[]; generation?: number }
71
+ | {
72
+ type: "invalidate";
73
+ kinds: ("code" | "css" | "config")[];
74
+ files: string[];
75
+ generation?: number;
76
+ devPlan?: DevChangePlan;
77
+ }
43
78
  | { type: "css-updated"; data: CssPayload }
44
- | { type: "pages-updated"; data: PagesBundlePayload };
79
+ | { type: "pages-updated"; data: PagesBundlePayload }
80
+ | { type: "build-status"; data: DevBuildStatus };
45
81
 
46
82
  export type BuilderMessage = BuilderReq | BuilderRes | BuilderEvent;
@@ -14,11 +14,13 @@ export const HMR_CLIENT_SCRIPT = `(function(){
14
14
  var refreshQueue = Promise.resolve();
15
15
  var overlayEl = null;
16
16
  var overlayLabelEl = null;
17
+ var overlayDetailEl = null;
17
18
  var overlayStyleEl = null;
18
19
  var overlayTimer = null;
19
20
  var overlayHideTimer = null;
20
21
  var overlayNextToken = 1;
21
22
  var overlayJobs = {};
23
+ var buildErrorStates = {};
22
24
  self.__AKAN_HMR_PHASE__ = null;
23
25
 
24
26
  self.$RefreshReg$ = self.$RefreshReg$ || function(type, id){
@@ -69,7 +71,20 @@ export const HMR_CLIENT_SCRIPT = `(function(){
69
71
  }
70
72
  return;
71
73
  }
72
- if (msg.type === "error") { console.error("[akan-hmr]", msg.message); return; }
74
+ if (msg.type === "build-status") { handleBuildStatus(msg); return; }
75
+ if (msg.type === "ok") {
76
+ clearBuildErrorOverlay({
77
+ phase: "build",
78
+ generation: typeof msg.generation === "number" ? msg.generation : Number.MAX_SAFE_INTEGER,
79
+ files: 0
80
+ });
81
+ return;
82
+ }
83
+ if (msg.type === "error") {
84
+ console.error("[akan-hmr]", msg.message);
85
+ showBuildErrorOverlay({ phase: "build", generation: 0, message: msg.message, files: 0 });
86
+ return;
87
+ }
73
88
  });
74
89
  socket.addEventListener("close", function(){ socket = null; schedule(); });
75
90
  socket.addEventListener("error", function(){ try { socket && socket.close(); } catch(e){} });
@@ -87,18 +102,25 @@ export const HMR_CLIENT_SCRIPT = `(function(){
87
102
  overlayStyleEl = document.createElement("style");
88
103
  overlayStyleEl.textContent =
89
104
  "@keyframes akan-hmr-spin{to{transform:rotate(360deg)}}" +
90
- ".__akan_hmr_overlay{position:fixed;left:16px;bottom:16px;z-index:2147483647;display:flex;align-items:center;gap:9px;padding:10px 12px;border-radius:999px;background:rgba(17,24,39,.94);color:#fff;font:500 13px/1.2 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;box-shadow:0 10px 28px rgba(0,0,0,.28);pointer-events:none;opacity:0;transform:translateY(6px);transition:opacity .15s ease,transform .15s ease;backdrop-filter:blur(8px)}" +
105
+ ".__akan_hmr_overlay{position:fixed;left:16px;bottom:16px;z-index:2147483647;display:flex;align-items:flex-start;gap:9px;max-width:min(420px,calc(100vw - 32px));padding:10px 12px;border-radius:16px;background:rgba(17,24,39,.94);color:#fff;font:500 13px/1.25 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;box-shadow:0 10px 28px rgba(0,0,0,.28);pointer-events:none;opacity:0;transform:translateY(6px);transition:opacity .15s ease,transform .15s ease;backdrop-filter:blur(8px)}" +
91
106
  ".__akan_hmr_overlay[data-show=true]{opacity:1;transform:translateY(0)}" +
92
- ".__akan_hmr_spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.32);border-top-color:#fff;border-radius:999px;animation:akan-hmr-spin .75s linear infinite;flex:none}" +
107
+ ".__akan_hmr_overlay[data-status=error]{background:rgba(127,29,29,.96);border:1px solid rgba(252,165,165,.55)}" +
108
+ ".__akan_hmr_overlay[data-status=ok]{background:rgba(6,95,70,.94);border:1px solid rgba(110,231,183,.45)}" +
109
+ ".__akan_hmr_spinner{width:14px;height:14px;margin-top:1px;border:2px solid rgba(255,255,255,.32);border-top-color:#fff;border-radius:999px;animation:akan-hmr-spin .75s linear infinite;flex:none}" +
110
+ ".__akan_hmr_overlay[data-status=error] .__akan_hmr_spinner,.__akan_hmr_overlay[data-status=ok] .__akan_hmr_spinner{animation:none;border-color:rgba(255,255,255,.72);border-top-color:rgba(255,255,255,.72)}" +
111
+ ".__akan_hmr_body{display:flex;flex-direction:column;gap:3px;min-width:0}" +
112
+ ".__akan_hmr_detail{font:400 12px/1.35 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:rgba(255,255,255,.82);white-space:pre-wrap;overflow-wrap:anywhere}" +
93
113
  "@media (prefers-reduced-motion:reduce){.__akan_hmr_overlay{transition:none}.__akan_hmr_spinner{animation:none}}";
94
114
  document.head.appendChild(overlayStyleEl);
95
115
  }
96
116
  overlayEl = document.createElement("div");
97
117
  overlayEl.className = "__akan_hmr_overlay";
118
+ overlayEl.setAttribute("data-status", "updating");
98
119
  overlayEl.setAttribute("role", "status");
99
120
  overlayEl.setAttribute("aria-live", "polite");
100
- overlayEl.innerHTML = '<span class="__akan_hmr_spinner" aria-hidden="true"></span><span data-akan-hmr-label>Updating...</span>';
121
+ overlayEl.innerHTML = '<span class="__akan_hmr_spinner" aria-hidden="true"></span><span class="__akan_hmr_body"><span data-akan-hmr-label>Updating...</span><span class="__akan_hmr_detail" data-akan-hmr-detail></span></span>';
101
122
  overlayLabelEl = overlayEl.querySelector("[data-akan-hmr-label]");
123
+ overlayDetailEl = overlayEl.querySelector("[data-akan-hmr-detail]");
102
124
  (document.body || document.documentElement).appendChild(overlayEl);
103
125
  return overlayEl;
104
126
  }
@@ -116,19 +138,25 @@ export const HMR_CLIENT_SCRIPT = `(function(){
116
138
  function showOverlayNow(){
117
139
  overlayTimer = null;
118
140
  if (activeOverlayTokens().length === 0) return;
141
+ if (hasBuildErrors()) {
142
+ renderBuildErrorOverlay();
143
+ return;
144
+ }
119
145
  var el = ensureOverlay();
146
+ el.setAttribute("data-status", "updating");
120
147
  if (overlayHideTimer) {
121
148
  clearTimeout(overlayHideTimer);
122
149
  overlayHideTimer = null;
123
150
  }
124
151
  if (overlayLabelEl) overlayLabelEl.textContent = latestOverlayLabel();
152
+ if (overlayDetailEl) overlayDetailEl.textContent = "";
125
153
  requestAnimationFrame(function(){ el.setAttribute("data-show", "true"); });
126
154
  }
127
155
 
128
156
  function beginHmrOverlay(label, immediate){
129
157
  var token = overlayNextToken++;
130
158
  overlayJobs[token] = label || "Updating...";
131
- if (overlayLabelEl) overlayLabelEl.textContent = latestOverlayLabel();
159
+ if (!hasBuildErrors() && overlayLabelEl) overlayLabelEl.textContent = latestOverlayLabel();
132
160
  if (overlayHideTimer) {
133
161
  clearTimeout(overlayHideTimer);
134
162
  overlayHideTimer = null;
@@ -145,11 +173,15 @@ export const HMR_CLIENT_SCRIPT = `(function(){
145
173
  function setHmrOverlayLabel(token, label){
146
174
  if (!overlayJobs[token]) return;
147
175
  overlayJobs[token] = label || "Updating...";
148
- if (overlayLabelEl) overlayLabelEl.textContent = latestOverlayLabel();
176
+ if (!hasBuildErrors() && overlayLabelEl) overlayLabelEl.textContent = latestOverlayLabel();
149
177
  }
150
178
 
151
179
  function endHmrOverlay(token){
152
180
  delete overlayJobs[token];
181
+ if (hasBuildErrors()) {
182
+ renderBuildErrorOverlay();
183
+ return;
184
+ }
153
185
  if (activeOverlayTokens().length > 0) {
154
186
  if (overlayLabelEl) overlayLabelEl.textContent = latestOverlayLabel();
155
187
  return;
@@ -166,10 +198,117 @@ export const HMR_CLIENT_SCRIPT = `(function(){
166
198
  overlayEl.parentNode.removeChild(overlayEl);
167
199
  overlayEl = null;
168
200
  overlayLabelEl = null;
201
+ overlayDetailEl = null;
169
202
  }
170
203
  }, 180);
171
204
  }
172
205
 
206
+ function handleBuildStatus(msg){
207
+ if (msg.status === "error") {
208
+ showBuildErrorOverlay(msg);
209
+ return;
210
+ }
211
+ if (msg.status === "ok") clearBuildErrorOverlay(msg);
212
+ }
213
+
214
+ function showBuildErrorOverlay(msg){
215
+ var phase = msg.phase || "build";
216
+ var generation = typeof msg.generation === "number" ? msg.generation : 0;
217
+ var previous = buildErrorStates[phase];
218
+ if (previous && generation < previous.generation) return;
219
+ buildErrorStates[phase] = {
220
+ phase: msg.phase || "build",
221
+ generation: generation,
222
+ message: msg.message || "Build failed",
223
+ files: typeof msg.files === "number" ? msg.files : 0
224
+ };
225
+ console.error("[akan-hmr] build failed", buildErrorStates[phase]);
226
+ renderBuildErrorOverlay();
227
+ }
228
+
229
+ function renderBuildErrorOverlay(){
230
+ if (!hasBuildErrors()) return;
231
+ if (overlayTimer) {
232
+ clearTimeout(overlayTimer);
233
+ overlayTimer = null;
234
+ }
235
+ if (overlayHideTimer) {
236
+ clearTimeout(overlayHideTimer);
237
+ overlayHideTimer = null;
238
+ }
239
+ var el = ensureOverlay();
240
+ var phases = buildErrorPhases();
241
+ var latest = latestBuildErrorState();
242
+ el.setAttribute("data-status", "error");
243
+ if (overlayLabelEl) overlayLabelEl.textContent = "Build failed: " + phases.join(", ");
244
+ if (overlayDetailEl) overlayDetailEl.textContent = formatBuildStatusDetail(latest, phases.length);
245
+ requestAnimationFrame(function(){ el.setAttribute("data-show", "true"); });
246
+ }
247
+
248
+ function clearBuildErrorOverlay(msg){
249
+ var phase = msg.phase || "build";
250
+ var current = buildErrorStates[phase];
251
+ if (!current) return;
252
+ var generation = typeof msg.generation === "number" ? msg.generation : 0;
253
+ var recovered = phase === "backend" ? generation >= current.generation : generation > current.generation;
254
+ if (!recovered) return;
255
+ delete buildErrorStates[phase];
256
+ if (hasBuildErrors()) {
257
+ renderBuildErrorOverlay();
258
+ return;
259
+ }
260
+ if (overlayHideTimer) clearTimeout(overlayHideTimer);
261
+ var el = ensureOverlay();
262
+ el.setAttribute("data-status", "ok");
263
+ if (overlayLabelEl) overlayLabelEl.textContent = "Build recovered";
264
+ if (overlayDetailEl) overlayDetailEl.textContent = formatBuildStatusDetail(msg);
265
+ requestAnimationFrame(function(){ el.setAttribute("data-show", "true"); });
266
+ overlayHideTimer = setTimeout(function(){
267
+ if (activeOverlayTokens().length > 0) {
268
+ showOverlayNow();
269
+ return;
270
+ }
271
+ if (!overlayEl) return;
272
+ overlayEl.setAttribute("data-show", "false");
273
+ overlayHideTimer = setTimeout(function(){
274
+ if (overlayEl && activeOverlayTokens().length === 0 && !hasBuildErrors() && overlayEl.parentNode) {
275
+ overlayEl.parentNode.removeChild(overlayEl);
276
+ overlayEl = null;
277
+ overlayLabelEl = null;
278
+ overlayDetailEl = null;
279
+ }
280
+ }, 180);
281
+ }, 900);
282
+ }
283
+
284
+ function hasBuildErrors(){
285
+ return Object.keys(buildErrorStates).length > 0;
286
+ }
287
+
288
+ function buildErrorPhases(){
289
+ return Object.keys(buildErrorStates).sort();
290
+ }
291
+
292
+ function latestBuildErrorState(){
293
+ var phases = buildErrorPhases();
294
+ var latest = buildErrorStates[phases[0]];
295
+ for (var i = 1; i < phases.length; i++) {
296
+ var next = buildErrorStates[phases[i]];
297
+ if (!latest || next.generation >= latest.generation) latest = next;
298
+ }
299
+ return latest;
300
+ }
301
+
302
+ function formatBuildStatusDetail(msg, failedPhaseCount){
303
+ var parts = [];
304
+ if (typeof msg.generation === "number" && msg.generation > 0) parts.push("generation " + msg.generation);
305
+ if (typeof msg.files === "number") parts.push(msg.files + " file" + (msg.files === 1 ? "" : "s"));
306
+ if (failedPhaseCount > 1) parts.push(failedPhaseCount + " failed phases");
307
+ var prefix = parts.length > 0 ? parts.join(" · ") : "";
308
+ if (!msg.message) return prefix;
309
+ return prefix ? prefix + "\\n" + msg.message : msg.message;
310
+ }
311
+
173
312
  function refreshRsc(msg){
174
313
  var started = performance.now();
175
314
  var overlayToken = beginHmrOverlay("Refreshing page...");
@@ -3,6 +3,7 @@ import { Logger } from "akanjs/common";
3
3
  import {
4
4
  BuilderRpc,
5
5
  type ClientManifest,
6
+ type DevBuildStatus,
6
7
  RouteClientCache,
7
8
  type RouteSeedIndex,
8
9
  RouteSeedIndexStore,
@@ -10,7 +11,7 @@ import {
10
11
  import type { RscWorker } from "../rscWorkerHost";
11
12
  import type { RenderState } from "../types";
12
13
  import type { ChangeKind } from "./changeBatch";
13
- import { type HmrWsData, HmrWsHub } from "./wsHub";
14
+ import { type HmrMessage, type HmrWsData, HmrWsHub } from "./wsHub";
14
15
 
15
16
  const APP_RUNTIME_METADATA_BASENAMES = new Set(["dict.ts", "sig.ts", "useClient.ts"]);
16
17
 
@@ -44,6 +45,34 @@ export function manifestClientEntriesForFiles(
44
45
  return entries;
45
46
  }
46
47
 
48
+ export function devBuildStatusToHmrMessage(
49
+ status: DevBuildStatus,
50
+ previous?: DevBuildStatus,
51
+ ): Extract<HmrMessage, { type: "build-status" }> | null {
52
+ if (!status.ok) {
53
+ return {
54
+ type: "build-status",
55
+ status: "error",
56
+ generation: status.generation,
57
+ phase: status.phase,
58
+ message: status.message,
59
+ files: status.files.length,
60
+ };
61
+ }
62
+ if (!previous || previous.ok) return null;
63
+ const recovered =
64
+ status.phase === "backend" ? status.generation >= previous.generation : status.generation > previous.generation;
65
+ if (!recovered) return null;
66
+ return {
67
+ type: "build-status",
68
+ status: "ok",
69
+ generation: status.generation,
70
+ phase: status.phase,
71
+ message: status.message,
72
+ files: status.files.length,
73
+ };
74
+ }
75
+
47
76
  export interface DevHmrControllerOptions {
48
77
  renderState: RenderState;
49
78
  rsc: RscWorker;
@@ -69,6 +98,7 @@ export class DevHmrController {
69
98
  readonly #clientEntriesByRouteId = new Map<string, Set<string>>();
70
99
  readonly #dirty = new Set<Exclude<ChangeKind, "ignore">>();
71
100
  readonly #dirtyFiles = new Set<string>();
101
+ readonly #buildStatusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>();
72
102
 
73
103
  constructor({ renderState, rsc, seedIndex, upgradeHmrWs }: DevHmrControllerOptions) {
74
104
  this.#renderState = renderState;
@@ -166,6 +196,9 @@ export class DevHmrController {
166
196
  onInvalidate: (ev) => {
167
197
  this.#recordInvalidate(ev.files, new Set(ev.kinds), ev.generation);
168
198
  },
199
+ onBuildStatus: (status) => {
200
+ this.#recordBuildStatus(status);
201
+ },
169
202
  onCssUpdated: (css) => {
170
203
  const started = Date.now();
171
204
  const cssBytesByUrl = Object.fromEntries(
@@ -217,6 +250,17 @@ export class DevHmrController {
217
250
  });
218
251
  }
219
252
 
253
+ #recordBuildStatus(status: DevBuildStatus): void {
254
+ const previous = this.#buildStatusByPhase.get(status.phase);
255
+ this.#buildStatusByPhase.set(status.phase, status);
256
+ const message = devBuildStatusToHmrMessage(status, previous);
257
+ if (!message) return;
258
+ this.#hub.broadcast(message);
259
+ this.#logger.verbose(
260
+ `[hmr] build-status status=${message.status} generation=${message.generation} phase=${message.phase} files=${message.files ?? 0}`,
261
+ );
262
+ }
263
+
220
264
  #createRouteCache() {
221
265
  return new RouteClientCache({
222
266
  buildRoute: async (routeId, { seeds, knownEntries, generation }) => {
@@ -1,4 +1,5 @@
1
1
  import { Logger } from "akanjs/common";
2
+ import type { BuildPhase } from "../artifact";
2
3
 
3
4
  export interface HmrWsData {
4
5
  kind: "akan-hmr";
@@ -21,6 +22,15 @@ export type HmrMessage =
21
22
  routeIds?: string[];
22
23
  }
23
24
  | { type: "css-update"; cssAssets?: Record<string, { cssUrl: string; cssRelPath: string }> }
25
+ | { type: "ok"; generation?: number }
26
+ | {
27
+ type: "build-status";
28
+ status: "building" | "error" | "ok";
29
+ generation: number;
30
+ phase: BuildPhase;
31
+ message?: string;
32
+ files?: number;
33
+ }
24
34
  | { type: "error"; message: string };
25
35
 
26
36
  export const HMR_WS_TOPIC = "__akan_hmr";