@akanjs/devkit 2.3.5 → 2.3.6-rc.1

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.
@@ -0,0 +1,485 @@
1
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface DevStabilityFixture {
5
+ appName: string;
6
+ appDir: string;
7
+ workspaceRoot: string;
8
+ port: number;
9
+ }
10
+
11
+ export interface DevStabilityHost {
12
+ proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
13
+ logs: string[];
14
+ markLog(): number;
15
+ waitForLog(pattern: RegExp, timeoutMs?: number): Promise<RegExpMatchArray>;
16
+ waitForLogSince(mark: number, pattern: RegExp, timeoutMs?: number): Promise<RegExpMatchArray>;
17
+ stop(): Promise<void>;
18
+ }
19
+
20
+ export interface DevStabilityHmrProbe {
21
+ ws: WebSocket;
22
+ messages: unknown[];
23
+ mark(): number;
24
+ waitForMessageSince(mark: number, predicate: (message: unknown) => boolean, timeoutMs?: number): Promise<unknown>;
25
+ waitForNoMessageSince(mark: number, predicate: (message: unknown) => boolean, quietMs?: number): Promise<void>;
26
+ close(): void;
27
+ }
28
+
29
+ const DEFAULT_TIMEOUT_MS = 60_000;
30
+
31
+ const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
32
+
33
+ export class DevStabilityHarness {
34
+ readonly workspaceRoot: string;
35
+ readonly appName: string;
36
+ readonly appDir: string;
37
+ readonly portOffset: number;
38
+ #host: DevStabilityHost | null = null;
39
+
40
+ constructor({
41
+ workspaceRoot = path.resolve(import.meta.dir, "../../../.."),
42
+ appName = `zz-dev-stability-${process.pid}-${Date.now()}`,
43
+ portOffset = 3_000 + Math.floor(Math.random() * 1_000),
44
+ }: {
45
+ workspaceRoot?: string;
46
+ appName?: string;
47
+ portOffset?: number;
48
+ } = {}) {
49
+ this.workspaceRoot = workspaceRoot;
50
+ this.appName = appName;
51
+ this.appDir = path.join(workspaceRoot, "apps", appName);
52
+ this.portOffset = portOffset;
53
+ }
54
+
55
+ async createFixture(): Promise<DevStabilityFixture> {
56
+ await rm(this.appDir, { recursive: true, force: true });
57
+ await Promise.all([
58
+ mkdir(path.join(this.appDir, "page"), { recursive: true }),
59
+ mkdir(path.join(this.appDir, "common"), { recursive: true }),
60
+ mkdir(path.join(this.appDir, "srvkit"), { recursive: true }),
61
+ mkdir(path.join(this.appDir, "ui"), { recursive: true }),
62
+ mkdir(path.join(this.appDir, "webkit"), { recursive: true }),
63
+ mkdir(path.join(this.appDir, "lib"), { recursive: true }),
64
+ mkdir(path.join(this.appDir, "env"), { recursive: true }),
65
+ mkdir(path.join(this.appDir, "public"), { recursive: true }),
66
+ ]);
67
+ await Promise.all([
68
+ this.writeFile(
69
+ "main.ts",
70
+ `import { AkanApp } from "akanjs/server";
71
+
72
+ const run = async () => {
73
+ await new AkanApp("./server").start();
74
+ };
75
+ void run();
76
+ `,
77
+ ),
78
+ this.writeFile(
79
+ "akan.config.ts",
80
+ `import type { AppConfig } from "akanjs";
81
+
82
+ const config: AppConfig = {};
83
+ export default config;
84
+ `,
85
+ ),
86
+ this.writeFile(
87
+ "package.json",
88
+ `{
89
+ "type": "module",
90
+ "name": "${this.appName}",
91
+ "version": "0.0.1"
92
+ }
93
+ `,
94
+ ),
95
+ this.writeFile(
96
+ "tsconfig.json",
97
+ `{
98
+ "extends": "../../tsconfig.json",
99
+ "compilerOptions": {
100
+ "allowJs": true,
101
+ "noEmit": true,
102
+ "incremental": true,
103
+ "resolveJsonModule": true,
104
+ "jsx": "preserve"
105
+ },
106
+ "include": ["./**/*.ts", "./**/*.tsx"]
107
+ }
108
+ `,
109
+ ),
110
+ this.writeFile(
111
+ "env/env.client.ts",
112
+ `import { getEnv } from "akanjs/base";
113
+
114
+ export const env = {
115
+ ...getEnv(),
116
+ } as const;
117
+ `,
118
+ ),
119
+ this.writeFile(
120
+ "env/env.server.ts",
121
+ `import { getEnv } from "akanjs/base";
122
+
123
+ export const env = {
124
+ ...getEnv(),
125
+ } as const;
126
+ `,
127
+ ),
128
+ this.writeFile(
129
+ "env/env.server.testing.ts",
130
+ `export { env } from "./env.server";
131
+ `,
132
+ ),
133
+ this.writeFile(
134
+ "lib/option.ts",
135
+ `import { AkanOption } from "akanjs/server";
136
+
137
+ export type ModulesOptions = Record<string, never>;
138
+ export const option = new AkanOption<ModulesOptions>();
139
+ `,
140
+ ),
141
+ this.writeFile(
142
+ "server.ts",
143
+ `import { AkanServer, AkanLib } from "akanjs/server";
144
+ import { backendMarker } from "./srvkit/backendMarker";
145
+
146
+ void backendMarker;
147
+
148
+ export const lib = new AkanLib("${this.appName}", {});
149
+ export const server = new AkanServer("${this.appName}", {
150
+ appName: "${this.appName}",
151
+ env: "local",
152
+ operation: "local",
153
+ publicOrigin: "http://localhost",
154
+ serveDomain: "localhost",
155
+ } as never, undefined, lib);
156
+ `,
157
+ ),
158
+ this.writeFile(
159
+ "page/_layout.tsx",
160
+ `import "./styles.css";
161
+ import type { LayoutProps } from "akanjs/client";
162
+
163
+ export default function Layout({ children }: LayoutProps) {
164
+ return <>{children}</>;
165
+ }
166
+ `,
167
+ ),
168
+ this.writeFile(
169
+ "page/_index.tsx",
170
+ `import { marker } from "../common/marker";
171
+ import { ClientMarker } from "../ui/ClientMarker";
172
+
173
+ export default function Page() {
174
+ return (
175
+ <main>
176
+ <h1>Dev stability fixture</h1>
177
+ <p data-testid="marker">{marker}</p>
178
+ <ClientMarker />
179
+ </main>
180
+ );
181
+ }
182
+ `,
183
+ ),
184
+ this.writeFile(
185
+ "page/styles.css",
186
+ `main {
187
+ color: black;
188
+ }
189
+ `,
190
+ ),
191
+ this.writeFile(
192
+ "common/marker.ts",
193
+ `export const marker = "initial-shared-marker";
194
+ `,
195
+ ),
196
+ this.writeFile(
197
+ "srvkit/backendMarker.ts",
198
+ `export const backendMarker = "initial-backend-marker";
199
+ `,
200
+ ),
201
+ this.writeFile(
202
+ "lib/_fixture/fixture.service.ts",
203
+ `import { serve } from "akanjs/service";
204
+
205
+ export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
206
+ `,
207
+ ),
208
+ this.writeFile(
209
+ "lib/_fixture/fixture.signal.ts",
210
+ `import { endpoint, internal } from "akanjs/signal";
211
+
212
+ import * as srv from "../srv";
213
+
214
+ export class FixtureInternal extends internal(srv.fixture, () => ({})) {}
215
+
216
+ export class FixtureEndpoint extends endpoint(srv.fixture, () => ({})) {}
217
+ `,
218
+ ),
219
+ this.writeFile(
220
+ "lib/_fixture/fixture.dictionary.ts",
221
+ `import { serviceDictionary } from "akanjs/dictionary";
222
+
223
+ import type { FixtureEndpoint } from "./fixture.signal";
224
+
225
+ export const dictionary = serviceDictionary(["en", "ko"])
226
+ .endpoint<FixtureEndpoint>(() => ({}))
227
+ .translate({
228
+ hello: ["Initial Dictionary", "초기 사전"],
229
+ removeMe: ["Remove Me", "삭제 예정"],
230
+ });
231
+ `,
232
+ ),
233
+ this.writeFile(
234
+ "ui/ClientMarker.tsx",
235
+ `export function ClientMarker() {
236
+ return <p data-testid="client-marker">initial-client-marker</p>;
237
+ }
238
+ `,
239
+ ),
240
+ this.writeFile(
241
+ "webkit/useMarker.ts",
242
+ `export const useMarker = () => "initial-webkit-marker";
243
+ `,
244
+ ),
245
+ ]);
246
+ const port = await this.resolvePort();
247
+ return { appName: this.appName, appDir: this.appDir, workspaceRoot: this.workspaceRoot, port };
248
+ }
249
+
250
+ async cleanup(): Promise<void> {
251
+ await this.stopHost();
252
+ await rm(this.appDir, { recursive: true, force: true });
253
+ }
254
+
255
+ async startHost(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHost> {
256
+ const logs: string[] = [];
257
+ const proc = Bun.spawn(["bash", "-lc", `bun run akan start ${JSON.stringify(this.appName)}`], {
258
+ cwd: this.workspaceRoot,
259
+ env: {
260
+ ...process.env,
261
+ AKAN_VERBOSE: "1",
262
+ NODE_NO_WARNINGS: "1",
263
+ PORT_OFFSET: String(this.portOffset),
264
+ },
265
+ stdout: "pipe",
266
+ stderr: "pipe",
267
+ stdin: "ignore",
268
+ });
269
+ const consume = async (stream: ReadableStream<Uint8Array> | null) => {
270
+ if (!stream) return;
271
+ const decoder = new TextDecoder();
272
+ const reader = stream.getReader();
273
+ try {
274
+ while (true) {
275
+ const { done, value } = await reader.read();
276
+ if (done) break;
277
+ logs.push(decoder.decode(value, { stream: true }));
278
+ }
279
+ } finally {
280
+ reader.releaseLock();
281
+ }
282
+ };
283
+ void consume(proc.stdout);
284
+ void consume(proc.stderr);
285
+ const host: DevStabilityHost = {
286
+ proc,
287
+ logs,
288
+ markLog: () => markLog(logs),
289
+ waitForLog: (pattern, waitMs) => waitForLog(logs, pattern, waitMs),
290
+ waitForLogSince: (mark, pattern, waitMs) => waitForLogSince(logs, mark, pattern, waitMs),
291
+ stop: async () => {
292
+ proc.kill("SIGTERM");
293
+ await Promise.race([proc.exited.catch(() => undefined), wait(3_000)]);
294
+ if (!proc.killed) proc.kill("SIGKILL");
295
+ },
296
+ };
297
+ this.#host = host;
298
+ await host.waitForLog(/backend ready pid=(\d+)|AkanApp gateway is running on port/, timeoutMs);
299
+ return host;
300
+ }
301
+
302
+ async stopHost(): Promise<void> {
303
+ await this.#host?.stop();
304
+ this.#host = null;
305
+ }
306
+
307
+ async writeFile(relativePath: string, contents: string): Promise<void> {
308
+ const target = path.join(this.appDir, relativePath);
309
+ await mkdir(path.dirname(target), { recursive: true });
310
+ await writeFile(target, contents);
311
+ }
312
+
313
+ async replaceText(relativePath: string, search: string | RegExp, replacement: string): Promise<void> {
314
+ const file = Bun.file(path.join(this.appDir, relativePath));
315
+ const contents = await file.text();
316
+ await this.writeFile(relativePath, contents.replace(search, replacement));
317
+ }
318
+
319
+ async removeFile(relativePath: string): Promise<void> {
320
+ await rm(path.join(this.appDir, relativePath), { force: true });
321
+ }
322
+
323
+ async waitForHttpText(text: string | RegExp, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string> {
324
+ const body = await this.tryWaitForHttpText(text, timeoutMs);
325
+ if (body) return body;
326
+ throw new Error(`Timed out waiting for HTTP text ${String(text)}`);
327
+ }
328
+
329
+ async tryWaitForHttpText(text: string | RegExp, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string | null> {
330
+ const port = await this.resolvePort();
331
+ const started = Date.now();
332
+ while (Date.now() - started < timeoutMs) {
333
+ const body = await fetch(`http://127.0.0.1:${port}/`)
334
+ .then((res) => res.text())
335
+ .catch(() => null);
336
+ if (body && (typeof text === "string" ? body.includes(text) : text.test(body))) return body;
337
+ await wait(100);
338
+ }
339
+ return null;
340
+ }
341
+
342
+ async connectHmr(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<WebSocket> {
343
+ const port = await this.resolvePort();
344
+ const started = Date.now();
345
+ while (Date.now() - started < timeoutMs) {
346
+ const ws = await new Promise<WebSocket | null>((resolve) => {
347
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/_akan/hmr`);
348
+ const timeout = setTimeout(() => {
349
+ socket.close();
350
+ resolve(null);
351
+ }, 750);
352
+ socket.addEventListener("open", () => {
353
+ clearTimeout(timeout);
354
+ resolve(socket);
355
+ });
356
+ socket.addEventListener("error", () => {
357
+ clearTimeout(timeout);
358
+ socket.close();
359
+ resolve(null);
360
+ });
361
+ });
362
+ if (ws) return ws;
363
+ await wait(100);
364
+ }
365
+ throw new Error("Timed out connecting HMR websocket");
366
+ }
367
+
368
+ async connectHmrProbe(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHmrProbe> {
369
+ const ws = await this.connectHmr(timeoutMs);
370
+ const messages: unknown[] = [];
371
+ ws.addEventListener("message", (event) => {
372
+ const raw = typeof event.data === "string" ? event.data : "";
373
+ try {
374
+ messages.push(JSON.parse(raw));
375
+ } catch {
376
+ /* ignore non-json websocket payloads */
377
+ }
378
+ });
379
+ return {
380
+ ws,
381
+ messages,
382
+ mark: () => messages.length,
383
+ waitForMessageSince: (mark, predicate, waitMs) => waitForHmrMessageSince(messages, mark, predicate, waitMs),
384
+ waitForNoMessageSince: (mark, predicate, quietMs) => waitForNoHmrMessageSince(messages, mark, predicate, quietMs),
385
+ close: () => ws.close(),
386
+ };
387
+ }
388
+
389
+ async tryConnectHmrProbe(timeoutMs = 3_000): Promise<DevStabilityHmrProbe | null> {
390
+ try {
391
+ return await this.connectHmrProbe(timeoutMs);
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ async waitForHmrMessage(
398
+ ws: WebSocket,
399
+ predicate: (message: unknown) => boolean,
400
+ timeoutMs = DEFAULT_TIMEOUT_MS,
401
+ ): Promise<unknown> {
402
+ return await new Promise((resolve, reject) => {
403
+ const timeout = setTimeout(() => {
404
+ ws.removeEventListener("message", onMessage);
405
+ reject(new Error("Timed out waiting for HMR message"));
406
+ }, timeoutMs);
407
+ const onMessage = (event: MessageEvent) => {
408
+ const raw = typeof event.data === "string" ? event.data : "";
409
+ let message: unknown;
410
+ try {
411
+ message = JSON.parse(raw);
412
+ } catch {
413
+ return;
414
+ }
415
+ if (!predicate(message)) return;
416
+ clearTimeout(timeout);
417
+ ws.removeEventListener("message", onMessage);
418
+ resolve(message);
419
+ };
420
+ ws.addEventListener("message", onMessage);
421
+ });
422
+ }
423
+
424
+ async resolvePort(): Promise<number> {
425
+ const apps = await readdir(path.join(this.workspaceRoot, "apps")).catch(() => []);
426
+ const appIndex = Math.max([...new Set([...apps, this.appName])].sort().indexOf(this.appName), 0);
427
+ return 8282 + appIndex + this.portOffset;
428
+ }
429
+ }
430
+
431
+ export async function waitForHmrMessageSince(
432
+ messages: unknown[],
433
+ mark: number,
434
+ predicate: (message: unknown) => boolean,
435
+ timeoutMs = DEFAULT_TIMEOUT_MS,
436
+ ): Promise<unknown> {
437
+ const started = Date.now();
438
+ while (Date.now() - started < timeoutMs) {
439
+ const found = messages.slice(mark).find(predicate);
440
+ if (found) return found;
441
+ await wait(50);
442
+ }
443
+ throw new Error(`Timed out waiting for HMR message since mark ${mark}`);
444
+ }
445
+
446
+ export async function waitForNoHmrMessageSince(
447
+ messages: unknown[],
448
+ mark: number,
449
+ predicate: (message: unknown) => boolean,
450
+ quietMs = 750,
451
+ ): Promise<void> {
452
+ const started = Date.now();
453
+ while (Date.now() - started < quietMs) {
454
+ const found = messages.slice(mark).find(predicate);
455
+ if (found) throw new Error(`Unexpected HMR message after mark ${mark}: ${JSON.stringify(found)}`);
456
+ await wait(50);
457
+ }
458
+ }
459
+
460
+ export async function waitForLog(
461
+ logs: string[],
462
+ pattern: RegExp,
463
+ timeoutMs = DEFAULT_TIMEOUT_MS,
464
+ ): Promise<RegExpMatchArray> {
465
+ return await waitForLogSince(logs, 0, pattern, timeoutMs);
466
+ }
467
+
468
+ export const markLog = (logs: string[]): number => logs.join("").length;
469
+
470
+ export async function waitForLogSince(
471
+ logs: string[],
472
+ mark: number,
473
+ pattern: RegExp,
474
+ timeoutMs = DEFAULT_TIMEOUT_MS,
475
+ ): Promise<RegExpMatchArray> {
476
+ const started = Date.now();
477
+ while (Date.now() - started < timeoutMs) {
478
+ const joined = logs.join("").slice(mark);
479
+ const match = joined.match(pattern);
480
+ if (match) return match;
481
+ await wait(50);
482
+ }
483
+ const tail = logs.join("").slice(mark).slice(-4_000);
484
+ throw new Error(`Timed out waiting for log pattern ${pattern} since mark ${mark}\nRecent logs:\n${tail}`);
485
+ }
@@ -15,11 +15,11 @@ or {
15
15
  JsModuleSource() as $source where {
16
16
  $source <: within JsImport(),
17
17
  not $filename <: r".*\.(?:test|spec)\.tsx?",
18
- $filename <: r".*apps/akasys/lib/projectBuild/[^/]+\.(?:constant|dictionary|document|service|signal|store)\.ts|.*apps/akasys/lib/projectBuild/[^/]+\.(?:Template|Unit|Util|View|Zone)\.tsx",
18
+ $filename <: r".*(?:apps|libs)/[^/]+/lib/[^/]+/[^/]+\.(?:ts|tsx)",
19
19
  $source <: r"\"\.\./\.\./.*\"",
20
20
  register_diagnostic(
21
21
  span = $source,
22
- message = "projectBuild module files should not import from two or more parent directories."
22
+ message = "Module files should not import from two or more parent directories."
23
23
  )
24
24
  }
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.5",
3
+ "version": "2.3.6-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.5",
35
+ "akanjs": "2.3.6-rc.1",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "^5.5.20",