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 +6 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/server/akanApp.ts +11 -5
- package/server/akanServer.ts +74 -3
- package/server/artifact/builderRpc.ts +6 -0
- package/server/artifact/ipcTypes.ts +38 -2
- package/server/hmr/clientScript.ts +145 -6
- package/server/hmr/devHmrController.ts +45 -1
- package/server/hmr/wsHub.ts +10 -0
- package/server/routing/apiRouter.ts +5 -1
- package/signal/index.ts +1 -0
- package/signal/openapi/index.ts +1 -0
- package/signal/openapi/openapi.ts +359 -0
- package/types/server/akanApp.d.ts +1 -0
- package/types/server/akanServer.d.ts +7 -1
- package/types/server/artifact/builderRpc.d.ts +3 -1
- package/types/server/artifact/ipcTypes.d.ts +22 -0
- package/types/server/hmr/clientScript.d.ts +1 -1
- package/types/server/hmr/devHmrController.d.ts +5 -2
- package/types/server/hmr/wsHub.d.ts +11 -0
- package/types/server/routing/apiRouter.d.ts +2 -1
- package/types/signal/index.d.ts +1 -0
- package/types/signal/openapi/index.d.ts +1 -0
- package/types/signal/openapi/openapi.d.ts +35 -0
- package/types/signal/openapi.d.ts +1 -0
package/CHANGELOG.md
CHANGED
package/README.ko.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Akan.js
|
|
2
2
|
|
|
3
|
-
[English](
|
|
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
|
-
[한국어](
|
|
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
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
|
-
|
|
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(
|
|
105
|
+
this.#replica = AkanApp.#parseReplicaConfig(resolvedOptions.replica);
|
|
103
106
|
this.#runtimeDir =
|
|
104
|
-
(
|
|
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(
|
|
108
|
-
this.#wsBasePort = Number(
|
|
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":
|
package/server/akanServer.ts
CHANGED
|
@@ -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
|
-
...
|
|
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 = {
|
|
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 } =
|
|
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
|
-
| {
|
|
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 === "
|
|
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:
|
|
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
|
-
".
|
|
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 }) => {
|
package/server/hmr/wsHub.ts
CHANGED
|
@@ -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";
|