@akanjs/devkit 3.0.0-alpha.96 → 3.0.0-alpha.97
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/codegenLock.ts
CHANGED
|
@@ -11,10 +11,10 @@ interface LockHolder {
|
|
|
11
11
|
/**
|
|
12
12
|
* A workspace-wide mutex over the generated source files every dev server in the workspace rewrites.
|
|
13
13
|
*
|
|
14
|
-
* `WatchRootResolver` narrows
|
|
15
|
-
*
|
|
16
|
-
* barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is
|
|
17
|
-
* rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
|
|
14
|
+
* `WatchRootResolver` narrows each dev server to its own app and its own lib dependencies, but two apps
|
|
15
|
+
* that share a lib still both watch it, so a save there reaches both builders and both regenerate the
|
|
16
|
+
* same barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is
|
|
17
|
+
* a rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
|
|
18
18
|
*
|
|
19
19
|
* A wait that expires proceeds *without* the lock rather than failing: this sits on the dev server's
|
|
20
20
|
* hot path, and stalling the file watcher is worse than the torn read `FileSys.writeTextAtomic` already
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
@@ -14,11 +14,23 @@ const makeWorkspace = async (dirs: string[]) => {
|
|
|
14
14
|
return root;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const writeManifest = (workspaceRoot: string, scope: string, libDeps: unknown) =>
|
|
18
|
+
writeFile(
|
|
19
|
+
path.join(workspaceRoot, scope, scope.startsWith("apps/") ? "akan.app.json" : "akan.lib.json"),
|
|
20
|
+
JSON.stringify({ libDeps }),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
interface AppStub {
|
|
24
|
+
paths?: Record<string, string[]>;
|
|
25
|
+
libDeps?: string[] | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const makeApp = (workspaceRoot: string, name: string, { paths = {}, libDeps = null }: AppStub = {}) =>
|
|
18
29
|
({
|
|
19
30
|
cwdPath: path.join(workspaceRoot, "apps", name),
|
|
20
31
|
workspace: { workspaceRoot },
|
|
21
32
|
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
33
|
+
getScanInfo: () => (libDeps ? { type: "app", libDeps } : null),
|
|
22
34
|
}) as unknown as App;
|
|
23
35
|
|
|
24
36
|
afterEach(async () => {
|
|
@@ -29,7 +41,7 @@ describe("WatchRootResolver", () => {
|
|
|
29
41
|
test("narrows the apps container to the app being served", async () => {
|
|
30
42
|
const workspaceRoot = await makeWorkspace(["apps/app1/page", "apps/app2/page", "libs/util"]);
|
|
31
43
|
const resolved = await new WatchRootResolver(
|
|
32
|
-
makeApp(workspaceRoot, "app1", { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] }),
|
|
44
|
+
makeApp(workspaceRoot, "app1", { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } }),
|
|
33
45
|
).resolve();
|
|
34
46
|
|
|
35
47
|
expect(resolved).toContain(path.join(workspaceRoot, "apps/app1"));
|
|
@@ -37,9 +49,57 @@ describe("WatchRootResolver", () => {
|
|
|
37
49
|
expect(resolved).not.toContain(path.join(workspaceRoot, "apps/app2"));
|
|
38
50
|
});
|
|
39
51
|
|
|
40
|
-
test("
|
|
52
|
+
test("narrows the libs container to the app's own dependencies", async () => {
|
|
53
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
|
|
54
|
+
const resolved = await new WatchRootResolver(
|
|
55
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] }, libDeps: ["util"] }),
|
|
56
|
+
).resolve();
|
|
57
|
+
|
|
58
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
|
|
59
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "libs"));
|
|
60
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "libs/shared"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("drops the libs container entirely when the app depends on no lib", async () => {
|
|
64
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util"]);
|
|
65
|
+
await writeManifest(workspaceRoot, "apps/app1", []);
|
|
66
|
+
const resolved = await new WatchRootResolver(
|
|
67
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
68
|
+
).resolve();
|
|
69
|
+
|
|
70
|
+
expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("takes the transitive closure from the synced manifests when nothing scanned in-process", async () => {
|
|
74
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared", "libs/unused"]);
|
|
75
|
+
await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
|
|
76
|
+
await writeManifest(workspaceRoot, "libs/shared", ["util"]);
|
|
77
|
+
await writeManifest(workspaceRoot, "libs/util", []);
|
|
78
|
+
await writeManifest(workspaceRoot, "libs/unused", []);
|
|
79
|
+
const resolved = await new WatchRootResolver(
|
|
80
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
81
|
+
).resolve();
|
|
82
|
+
|
|
83
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs/shared"));
|
|
84
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
|
|
85
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "libs/unused"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("keeps the libs container whole when the app manifest is missing", async () => {
|
|
89
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
|
|
90
|
+
const resolved = await new WatchRootResolver(
|
|
91
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
92
|
+
).resolve();
|
|
93
|
+
|
|
94
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("keeps the libs container whole when a dependency lib is unsynced", async () => {
|
|
41
98
|
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
|
|
42
|
-
|
|
99
|
+
await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
|
|
100
|
+
const resolved = await new WatchRootResolver(
|
|
101
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
102
|
+
).resolve();
|
|
43
103
|
|
|
44
104
|
expect(resolved).toContain(path.join(workspaceRoot, "libs"));
|
|
45
105
|
});
|
|
@@ -48,9 +108,11 @@ describe("WatchRootResolver", () => {
|
|
|
48
108
|
const workspaceRoot = await makeWorkspace(["apps/app1/page", "pkgs/akanjs/base"]);
|
|
49
109
|
const resolved = await new WatchRootResolver(
|
|
50
110
|
makeApp(workspaceRoot, "app1", {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
111
|
+
paths: {
|
|
112
|
+
akanjs: ["./pkgs/akanjs/index.ts"],
|
|
113
|
+
"akanjs/*": ["./pkgs/akanjs/*"],
|
|
114
|
+
missing: ["./pkgs/nothing/index.ts"],
|
|
115
|
+
},
|
|
54
116
|
}),
|
|
55
117
|
).resolve();
|
|
56
118
|
|
|
@@ -60,7 +122,7 @@ describe("WatchRootResolver", () => {
|
|
|
60
122
|
|
|
61
123
|
test("always watches the app's page tree", async () => {
|
|
62
124
|
const workspaceRoot = await makeWorkspace(["apps/app1/page"]);
|
|
63
|
-
const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1"
|
|
125
|
+
const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1")).resolve();
|
|
64
126
|
|
|
65
127
|
expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
|
|
66
128
|
});
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The directories one app's dev watcher follows, resolved from its tsconfig `paths`.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* rewrite the same generated barrels
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Two aliases strip down to a workspace container rather than a package, and taking either verbatim puts
|
|
10
|
+
* code the app never imports under the watcher — where a save rebuilds, restarts and reloads it for
|
|
11
|
+
* nothing, and both builders rewrite the same generated barrels:
|
|
12
|
+
*
|
|
13
|
+
* - `@apps/*` becomes the `apps/` container. Apps are leaves of the workspace graph — never one another's
|
|
14
|
+
* dependencies — so it is replaced by this app's own directory.
|
|
15
|
+
* - `@libs/*` becomes the `libs/` container. A lib is a dependency only if the app reaches it, so it is
|
|
16
|
+
* replaced by the app's own lib dependencies, transitively. The set is resolved once, at watcher
|
|
17
|
+
* install: a lib that becomes a dependency mid-session needs a fresh `akan start` anyway, because the
|
|
18
|
+
* import that made it one also needs a `sync`.
|
|
19
|
+
*
|
|
20
|
+
* Failing to resolve that set keeps the container whole, because a wrong narrowing is a dev server that
|
|
21
|
+
* silently ignores edits.
|
|
13
22
|
*/
|
|
14
23
|
export class WatchRootResolver {
|
|
15
24
|
#app: App;
|
|
@@ -22,6 +31,8 @@ export class WatchRootResolver {
|
|
|
22
31
|
const tsconfig = await this.#app.getTsConfig();
|
|
23
32
|
const appDir = path.resolve(this.#app.cwdPath);
|
|
24
33
|
const appsContainer = path.dirname(appDir);
|
|
34
|
+
const libsContainer = path.resolve(this.#app.workspace.workspaceRoot, "libs");
|
|
35
|
+
const libRoots = await this.#resolveLibRoots(libsContainer);
|
|
25
36
|
const set = new Set<string>();
|
|
26
37
|
set.add(path.resolve(`${this.#app.cwdPath}/page`));
|
|
27
38
|
for (const targets of Object.values(tsconfig.compilerOptions.paths ?? {})) {
|
|
@@ -31,10 +42,52 @@ export class WatchRootResolver {
|
|
|
31
42
|
// Strip the trailing filename and glob so we watch the package root dir.
|
|
32
43
|
const cleaned = target.replace(/\/?\*+.*$/, "").replace(/\/[^/]+\.[^/]+$/, "");
|
|
33
44
|
const resolved = path.resolve(this.#app.workspace.workspaceRoot, cleaned);
|
|
45
|
+
if (resolved === libsContainer && libRoots) {
|
|
46
|
+
for (const root of libRoots) set.add(root);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
34
49
|
const root = resolved === appsContainer ? appDir : resolved;
|
|
35
50
|
if (fs.existsSync(root)) set.add(root);
|
|
36
51
|
}
|
|
37
52
|
}
|
|
38
53
|
return [...set];
|
|
39
54
|
}
|
|
55
|
+
|
|
56
|
+
async #resolveLibRoots(libsContainer: string): Promise<string[] | null> {
|
|
57
|
+
const libDeps = await this.#resolveLibDeps(libsContainer);
|
|
58
|
+
if (!libDeps) return null;
|
|
59
|
+
return libDeps.map((name) => path.join(libsContainer, name)).filter((dir) => fs.existsSync(dir));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #resolveLibDeps(libsContainer: string): Promise<string[] | null> {
|
|
63
|
+
const scanInfo = this.#app.getScanInfo({ allowEmpty: true });
|
|
64
|
+
// Already transitive, and present whenever this runs in a process that scanned. The builder and the
|
|
65
|
+
// idle watcher run in processes that did not, so they take the manifests `scan` wrote instead.
|
|
66
|
+
if (scanInfo?.type === "app") return scanInfo.libDeps;
|
|
67
|
+
const direct = await WatchRootResolver.#readManifestLibDeps(path.join(this.#app.cwdPath, "akan.app.json"));
|
|
68
|
+
if (!direct) return null;
|
|
69
|
+
const closure = new Set<string>();
|
|
70
|
+
const queue = [...direct];
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const name = queue.shift();
|
|
73
|
+
if (!name || closure.has(name)) continue;
|
|
74
|
+
closure.add(name);
|
|
75
|
+
const nested = await WatchRootResolver.#readManifestLibDeps(path.join(libsContainer, name, "akan.lib.json"));
|
|
76
|
+
// An unsynced lib's own dependencies are unknowable, so no root can be ruled out.
|
|
77
|
+
if (!nested) return null;
|
|
78
|
+
queue.push(...nested);
|
|
79
|
+
}
|
|
80
|
+
return [...closure];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async #readManifestLibDeps(manifestPath: string): Promise<string[] | null> {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(await readFile(manifestPath, "utf8")) as { libDeps?: unknown };
|
|
86
|
+
if (!Array.isArray(parsed.libDeps)) return null;
|
|
87
|
+
return parsed.libDeps.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
88
|
+
} catch {
|
|
89
|
+
// Missing before the first sync, and unparseable while sync is mid-write.
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
40
93
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.97",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@langchain/openai": "^1.4.6",
|
|
45
45
|
"@tailwindcss/node": "^4.3.0",
|
|
46
46
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "3.0.0-alpha.
|
|
47
|
+
"akanjs": "3.0.0-alpha.97",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^14.0.3",
|
|
50
50
|
"dayjs": "^1.11.20",
|