@akanjs/devkit 2.1.0-rc.9 → 2.1.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.
@@ -25,8 +25,8 @@ const packageJson: PackageJson = {
25
25
 
26
26
  const app = { name: "portal" } as never;
27
27
  const baseDevEnv = {
28
- repoName: "akansoft",
29
- serveDomain: "akamir.com",
28
+ repoName: "akanjs",
29
+ serveDomain: "akanjs.com",
30
30
  env: "debug" as const,
31
31
  portOffset: 0,
32
32
  workspaceRoot: "/workspace",
@@ -37,9 +37,9 @@ describe("AkanAppConfig", () => {
37
37
  const config = new AkanAppConfig(app, ["shared"], packageJson, {}, baseDevEnv);
38
38
 
39
39
  expect([...config.domains].sort()).toEqual([
40
- "portal-debug.akamir.com",
41
- "portal-develop.akamir.com",
42
- "portal-main.akamir.com",
40
+ "portal-debug.akanjs.com",
41
+ "portal-develop.akanjs.com",
42
+ "portal-main.akanjs.com",
43
43
  ]);
44
44
  expect(config.basePaths.size).toBe(0);
45
45
  expect(config.i18n.defaultLocale).toBe("en");
@@ -95,10 +95,10 @@ describe("AkanAppConfig", () => {
95
95
  expect([...config.domains].sort()).toEqual(["qa.root.local", "root.local"]);
96
96
  expect([...config.basePaths]).toEqual(["admin"]);
97
97
  expect([...(config.subRoutes.get("admin") ?? [])].sort()).toEqual([
98
- "admin-debug.akamir.com",
99
- "admin-develop.akamir.com",
100
- "admin-main.akamir.com",
101
- "admin-qa.akamir.com",
98
+ "admin-debug.akanjs.com",
99
+ "admin-develop.akanjs.com",
100
+ "admin-main.akanjs.com",
101
+ "admin-qa.akanjs.com",
102
102
  "admin.local",
103
103
  "admin.main.local",
104
104
  ]);
@@ -132,6 +132,7 @@ describe("AkanAppConfig", () => {
132
132
  react: "19.0.0",
133
133
  "react-dom": "19.0.0",
134
134
  "react-server-dom-webpack": "19.0.0",
135
+ croner: akanPackageJson.peerDependencies?.croner,
135
136
  sharp: "1.0.0",
136
137
  "@external/runtime": "2.0.0",
137
138
  },
@@ -168,10 +169,45 @@ describe("AkanAppConfig", () => {
168
169
  react: runtimeDependencies.react,
169
170
  "react-dom": runtimeDependencies["react-dom"],
170
171
  "react-server-dom-webpack": runtimeDependencies["react-server-dom-webpack"],
172
+ croner: runtimeDependencies.croner,
171
173
  sharp: runtimeDependencies.sharp,
172
174
  });
173
175
  });
174
176
 
177
+ test("adds backend runtime packages by database mode", () => {
178
+ const runtimeDependencies = { ...akanPackageJson.dependencies, ...akanPackageJson.peerDependencies };
179
+ const singleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "single" }, baseDevEnv);
180
+ const multipleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "multiple" }, baseDevEnv);
181
+ const clusterConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "cluster" }, baseDevEnv);
182
+
183
+ expect(singleConfig.getProductionPackageJson().dependencies).toMatchObject({
184
+ croner: runtimeDependencies.croner,
185
+ });
186
+ expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("ioredis");
187
+ expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("bullmq");
188
+ expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
189
+ expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
190
+ expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("protobufjs");
191
+
192
+ expect(multipleConfig.getProductionPackageJson().dependencies).toMatchObject({
193
+ "@libsql/client": runtimeDependencies["@libsql/client"],
194
+ bullmq: runtimeDependencies.bullmq,
195
+ croner: runtimeDependencies.croner,
196
+ ioredis: runtimeDependencies.ioredis,
197
+ protobufjs: runtimeDependencies.protobufjs,
198
+ });
199
+ expect(multipleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
200
+
201
+ expect(clusterConfig.getProductionPackageJson().dependencies).toMatchObject({
202
+ bullmq: runtimeDependencies.bullmq,
203
+ croner: runtimeDependencies.croner,
204
+ ioredis: runtimeDependencies.ioredis,
205
+ postgres: runtimeDependencies.postgres,
206
+ protobufjs: runtimeDependencies.protobufjs,
207
+ });
208
+ expect(clusterConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
209
+ });
210
+
175
211
  test("normalizes multiple mobile targets and validates base paths", () => {
176
212
  const config = new AkanAppConfig(
177
213
  app,
@@ -50,7 +50,18 @@ const DEFAULT_OPTIMIZE_IMPORTS = [
50
50
  const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] as const;
51
51
  const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
52
52
  const NATIVE_RUNTIME_PACKAGES = ["sharp"] as const;
53
- const AKAN_RUNTIME_PACKAGES = new Set<string>([...SSR_RUNTIME_PACKAGES, ...NATIVE_RUNTIME_PACKAGES]);
53
+ const DEFAULT_BACKEND_RUNTIME_PACKAGES = ["croner"] as const;
54
+ const DATABASE_MODE_RUNTIME_PACKAGES = {
55
+ single: [],
56
+ multiple: ["@libsql/client", "bullmq", "ioredis", "protobufjs"],
57
+ cluster: ["bullmq", "ioredis", "postgres", "protobufjs"],
58
+ } satisfies Record<DatabaseMode, readonly string[]>;
59
+ const AKAN_RUNTIME_PACKAGES = new Set<string>([
60
+ ...SSR_RUNTIME_PACKAGES,
61
+ ...NATIVE_RUNTIME_PACKAGES,
62
+ ...DEFAULT_BACKEND_RUNTIME_PACKAGES,
63
+ ...Object.values(DATABASE_MODE_RUNTIME_PACKAGES).flat(),
64
+ ]);
54
65
  const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
55
66
  deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
56
67
  imageSizes: [32, 48, 64, 96, 128, 256, 384],
@@ -272,6 +283,15 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
272
283
  if (AKAN_RUNTIME_PACKAGES.has(lib))
273
284
  return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
274
285
  }
286
+ #getProductionRuntimePackages() {
287
+ return [
288
+ ...this.externalLibs,
289
+ ...SSR_RUNTIME_PACKAGES,
290
+ ...NATIVE_RUNTIME_PACKAGES,
291
+ ...DEFAULT_BACKEND_RUNTIME_PACKAGES,
292
+ ...DATABASE_MODE_RUNTIME_PACKAGES[this.defaultDatabaseMode],
293
+ ];
294
+ }
275
295
  getProductionPackageJson(data: Partial<PackageJson> = {}): PackageJson {
276
296
  return {
277
297
  name: this.app.name,
@@ -279,7 +299,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
279
299
  version: "1.0.0",
280
300
  main: "./main.js",
281
301
  dependencies: Object.fromEntries(
282
- [...new Set([...this.externalLibs, ...SSR_RUNTIME_PACKAGES, ...NATIVE_RUNTIME_PACKAGES])].map((lib) => {
302
+ [...new Set(this.#getProductionRuntimePackages())].map((lib) => {
283
303
  const version = this.#resolveProductionDependencyVersion(lib);
284
304
  if (!version) throw new Error(`Dependency ${lib} not found in package.json`);
285
305
  return [lib, version];
@@ -0,0 +1,10 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
3
+
4
+ describe("ApplicationBuildRunner", () => {
5
+ test("externalizes Akan optional backend dependencies", () => {
6
+ expect(AKAN_OPTIONAL_BACKEND_EXTERNALS).toEqual(
7
+ expect.arrayContaining(["@libsql/client", "bullmq", "croner", "ioredis", "postgres", "protobufjs"]),
8
+ );
9
+ });
10
+ });
@@ -47,10 +47,21 @@ const SSR_RENDER_EXTERNALS = [
47
47
  "react/jsx-dev-runtime",
48
48
  "react-dom",
49
49
  "react-dom/server.browser",
50
+ "react-server-dom-webpack",
51
+ "react-server-dom-webpack/server.node",
50
52
  "react-server-dom-webpack/client.node",
51
53
  "react-server-dom-webpack/client.browser",
52
54
  ] as const;
53
55
 
56
+ export const AKAN_OPTIONAL_BACKEND_EXTERNALS = [
57
+ "@libsql/client",
58
+ "bullmq",
59
+ "croner",
60
+ "ioredis",
61
+ "postgres",
62
+ "protobufjs",
63
+ ] as const;
64
+
54
65
  export class ApplicationBuildRunner {
55
66
  #app: App;
56
67
  #fast: boolean;
@@ -161,7 +172,9 @@ export class ApplicationBuildRunner {
161
172
 
162
173
  async #buildBackend() {
163
174
  const akanConfig = await this.#app.getConfig();
164
- const backendExternals = [...new Set([...akanConfig.externalLibs, ...SSR_RENDER_EXTERNALS])];
175
+ const backendExternals = [
176
+ ...new Set([...akanConfig.externalLibs, ...SSR_RENDER_EXTERNALS, ...AKAN_OPTIONAL_BACKEND_EXTERNALS]),
177
+ ];
165
178
  const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
166
179
  for (const entrypoint of backendEntryPoints) {
167
180
  if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
@@ -183,8 +196,7 @@ export class ApplicationBuildRunner {
183
196
  conditions: ["react-server"],
184
197
  // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
185
198
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
186
- plugins:
187
- akanConfig.externalLibs.length > 0 ? [this.#createExternalSpecifiersPlugin(akanConfig.externalLibs)] : [],
199
+ plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
188
200
  });
189
201
  return {
190
202
  entrypoints: backendEntryPoints.length + 1,
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+
3
+ export const SIGNAL_TEST_PRELOAD_PATH = "test/signalTest.preload.ts";
4
+
5
+ export interface SignalTestPreloadTarget {
6
+ cwdPath: string;
7
+ }
8
+
9
+ export async function resolveSignalTestPreloadPath(target: SignalTestPreloadTarget) {
10
+ const candidates: string[] = [];
11
+ const addResolvedPackageCandidate = (basePath: string) => {
12
+ try {
13
+ candidates.push(
14
+ path.join(path.dirname(Bun.resolveSync("akanjs/package.json", basePath)), SIGNAL_TEST_PRELOAD_PATH),
15
+ );
16
+ } catch {
17
+ // Source workspaces and published installs can resolve Akan packages from different roots.
18
+ }
19
+ };
20
+
21
+ addResolvedPackageCandidate(target.cwdPath);
22
+ addResolvedPackageCandidate(process.cwd());
23
+ addResolvedPackageCandidate(path.dirname(Bun.main));
24
+ addResolvedPackageCandidate(import.meta.dir);
25
+
26
+ candidates.push(
27
+ path.join(target.cwdPath, "../../node_modules/akanjs", SIGNAL_TEST_PRELOAD_PATH),
28
+ path.join(target.cwdPath, "../../pkgs/akanjs", SIGNAL_TEST_PRELOAD_PATH),
29
+ path.join(process.cwd(), "node_modules/akanjs", SIGNAL_TEST_PRELOAD_PATH),
30
+ path.join(process.cwd(), "pkgs/akanjs", SIGNAL_TEST_PRELOAD_PATH),
31
+ path.join(path.dirname(Bun.main), "../../akanjs", SIGNAL_TEST_PRELOAD_PATH),
32
+ path.resolve(import.meta.dir, "../../akanjs", SIGNAL_TEST_PRELOAD_PATH),
33
+ );
34
+
35
+ for (const candidate of [...new Set(candidates)]) {
36
+ if (await Bun.file(candidate).exists()) return candidate;
37
+ }
38
+
39
+ throw new Error(`Failed to locate ${SIGNAL_TEST_PRELOAD_PATH} from ${target.cwdPath}`);
40
+ }
@@ -0,0 +1,75 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { resolveSsrPageEntries } from "./implicitRootLayout";
6
+
7
+ const tempRoots: string[] = [];
8
+
9
+ const makeTempRoot = async () => {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-implicit-root-layout-"));
11
+ tempRoots.push(root);
12
+ return root;
13
+ };
14
+
15
+ const write = async (filePath: string, content: string) => {
16
+ await mkdir(path.dirname(filePath), { recursive: true });
17
+ await writeFile(filePath, content);
18
+ };
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
22
+ });
23
+
24
+ describe("resolveSsrPageEntries", () => {
25
+ test("inherits root layout static exports for grouped root boundaries", async () => {
26
+ const appRoot = await makeTempRoot();
27
+ const pageRoot = path.join(appRoot, "page");
28
+ const rootLayoutPath = path.join(pageRoot, "_layout.tsx");
29
+ const groupedLayoutPath = path.join(pageRoot, "(home)", "_layout.tsx");
30
+
31
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
32
+ await write(rootLayoutPath, 'export const theme = "dark";\nexport const fonts = [{ name: "pretendard" }];\n');
33
+ await write(groupedLayoutPath, "export default function Layout({ children }) { return children; }\n");
34
+
35
+ const entries = await resolveSsrPageEntries({
36
+ appCwdPath: appRoot,
37
+ appName: "demo",
38
+ pageKeys: ["./_layout.tsx", "./(home)/_layout.tsx", "./(home)/_index.tsx"],
39
+ });
40
+
41
+ const groupedRoot = entries.find((entry) => entry.key === "./(home)/__root_layout.tsx");
42
+ expect(groupedRoot).toBeDefined();
43
+ expect(groupedRoot?.seedAbsPaths).toContain(rootLayoutPath);
44
+ expect(groupedRoot?.seedAbsPaths).toContain(groupedLayoutPath);
45
+
46
+ const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
47
+ expect(generatedSource).toContain('import * as inheritedLayout from "../../../page/_layout.tsx";');
48
+ expect(generatedSource).not.toContain("<System.Provider");
49
+ expect(generatedSource).toContain(
50
+ "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
51
+ );
52
+ });
53
+
54
+ test("keeps system provider for grouped root boundaries without an ancestor root", async () => {
55
+ const appRoot = await makeTempRoot();
56
+ const pageRoot = path.join(appRoot, "page");
57
+ const groupedLayoutPath = path.join(pageRoot, "(home)", "_layout.tsx");
58
+
59
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
60
+ await write(groupedLayoutPath, 'export const theme = "dark";\n');
61
+
62
+ const entries = await resolveSsrPageEntries({
63
+ appCwdPath: appRoot,
64
+ appName: "demo",
65
+ pageKeys: ["./(home)/_layout.tsx", "./(home)/_index.tsx"],
66
+ });
67
+
68
+ const groupedRoot = entries.find((entry) => entry.key === "./(home)/__root_layout.tsx");
69
+ expect(groupedRoot).toBeDefined();
70
+
71
+ const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
72
+ expect(generatedSource).toContain("<System.Provider");
73
+ expect(generatedSource).toContain("theme={userLayout.theme ?? inheritedLayout.theme}");
74
+ });
75
+ });
@@ -68,6 +68,23 @@ function findRootBoundaries(pageKeys: string[], appCwdPath: string, basePaths: I
68
68
  return [...boundaries.values()].sort((a, b) => a.segments.join("/").localeCompare(b.segments.join("/")));
69
69
  }
70
70
 
71
+ function hasAncestorRootBoundary(boundary: RootBoundary, boundaries: RootBoundary[]): boolean {
72
+ return boundaries.some(
73
+ (candidate) =>
74
+ candidate !== boundary &&
75
+ candidate.segments.length < boundary.segments.length &&
76
+ candidate.segments.every((segment, index) => boundary.segments[index] === segment),
77
+ );
78
+ }
79
+
80
+ function findExplicitRootLayoutAbsPath(pageKeys: string[], appCwdPath: string): string | null {
81
+ const rootLayoutKey = pageKeys.find((key) => {
82
+ const segments = getRootBoundarySegments(key);
83
+ return segments !== null && segments.length === 0;
84
+ });
85
+ return rootLayoutKey ? path.resolve(appCwdPath, "page", rootLayoutKey.replace(/^\.\//, "")) : null;
86
+ }
87
+
71
88
  function routePrefixForSegments(segments: string[]): string | null {
72
89
  const visible = segments.filter((segment) => !/^\(.+\)$/.test(segment));
73
90
  return visible[0] ?? null;
@@ -86,7 +103,9 @@ async function writeGeneratedRootLayoutFile(opts: {
86
103
  appCwdPath: string;
87
104
  appName: string;
88
105
  boundary: RootBoundary;
106
+ rootSourceAbsPath: string | null;
89
107
  includeStInit: boolean;
108
+ includeSystemProvider: boolean;
90
109
  }): Promise<string> {
91
110
  await assertEnvClientConvention(opts.appCwdPath, opts.appName);
92
111
  const absPath = implicitRootLayoutAbsPath(opts.appCwdPath, opts.boundary.segments);
@@ -95,14 +114,29 @@ async function writeGeneratedRootLayoutFile(opts: {
95
114
  ? path.relative(path.dirname(absPath), opts.boundary.sourceAbsPath).split(path.sep).join("/")
96
115
  : null;
97
116
  const sourceSpecifier = sourceRel ? (sourceRel.startsWith(".") ? sourceRel : `./${sourceRel}`) : null;
117
+ const inheritedSourceAbsPath =
118
+ opts.rootSourceAbsPath && opts.rootSourceAbsPath !== opts.boundary.sourceAbsPath ? opts.rootSourceAbsPath : null;
119
+ const inheritedSourceRel = inheritedSourceAbsPath
120
+ ? path.relative(path.dirname(absPath), inheritedSourceAbsPath).split(path.sep).join("/")
121
+ : null;
122
+ const inheritedSourceSpecifier = inheritedSourceRel
123
+ ? inheritedSourceRel.startsWith(".")
124
+ ? inheritedSourceRel
125
+ : `./${inheritedSourceRel}`
126
+ : null;
98
127
  const clientImport = opts.includeStInit
99
128
  ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
100
129
  : `import "@apps/${opts.appName}/client";\n`;
130
+ const inheritedImport = inheritedSourceSpecifier
131
+ ? `import * as inheritedLayout from ${JSON.stringify(inheritedSourceSpecifier)};\n`
132
+ : "const inheritedLayout = {};\n";
101
133
  const prefix = routePrefixForSegments(opts.boundary.segments);
102
134
  const userImport = sourceSpecifier
103
135
  ? `import UserLayout, * as userLayout from ${JSON.stringify(sourceSpecifier)};\n`
104
136
  : "const UserLayout = ({ children }) => children;\nconst userLayout = {};\n";
105
- const source = `import type { LayoutProps, PageProps } from "akanjs/client";\nimport { loadFonts } from "akanjs/client";\nimport { System } from "akanjs/ui";\nimport { env } from "@apps/${opts.appName}/env/env.client";\n${clientImport}${userImport}\nconst userFonts = userLayout.fonts ?? [];\nconst defaultFonts = userFonts.filter((font) => font.default);\nif (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");\nconst defaultFont = defaultFonts[0];\nconst defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;\n\nexport async function generateHead(props: PageProps) {\n if (userLayout.generateHead) return userLayout.generateHead(props);\n return userLayout.head;\n}\n\nexport default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {\n return (\n <System.Provider\n of={GeneratedLayout as never}\n appName=${JSON.stringify(opts.appName)}\n ${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}\n manifest={userLayout.manifest}\n env={env}\n theme={userLayout.theme}\n fonts={loadFonts(userFonts)}\n className={defaultFontClassName}\n gaTrackingId={userLayout.gaTrackingId}\n layoutStyle={userLayout.layoutStyle}\n reconnect={userLayout.reconnect ?? false}\n >\n <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>\n </System.Provider>\n );\n}\n`;
137
+ const source = opts.includeSystemProvider
138
+ ? `import type { LayoutProps, PageProps } from "akanjs/client";\nimport { loadFonts } from "akanjs/client";\nimport { System } from "akanjs/ui";\nimport { env } from "@apps/${opts.appName}/env/env.client";\n${clientImport}${inheritedImport}${userImport}\nconst userFonts = userLayout.fonts ?? inheritedLayout.fonts ?? [];\nconst defaultFonts = userFonts.filter((font) => font.default);\nif (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");\nconst defaultFont = defaultFonts[0];\nconst defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;\n\nexport async function generateHead(props: PageProps) {\n if (userLayout.generateHead) return userLayout.generateHead(props);\n if (userLayout.head !== undefined) return userLayout.head;\n if (inheritedLayout.generateHead) return inheritedLayout.generateHead(props);\n return inheritedLayout.head;\n}\n\nexport default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {\n return (\n <System.Provider\n of={GeneratedLayout as never}\n appName=${JSON.stringify(opts.appName)}\n ${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}\n manifest={userLayout.manifest ?? inheritedLayout.manifest}\n env={env}\n theme={userLayout.theme ?? inheritedLayout.theme}\n fonts={loadFonts(userFonts)}\n className={defaultFontClassName}\n gaTrackingId={userLayout.gaTrackingId ?? inheritedLayout.gaTrackingId}\n layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}\n reconnect={userLayout.reconnect ?? inheritedLayout.reconnect ?? false}\n >\n <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>\n </System.Provider>\n );\n}\n`
139
+ : `import type { LayoutProps, PageProps } from "akanjs/client";\n${inheritedImport}${userImport}\nexport async function generateHead(props: PageProps) {\n if (userLayout.generateHead) return userLayout.generateHead(props);\n if (userLayout.head !== undefined) return userLayout.head;\n if (inheritedLayout.generateHead) return inheritedLayout.generateHead(props);\n return inheritedLayout.head;\n}\n\nexport default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {\n return <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;\n}\n`;
106
140
  await Bun.write(absPath, source);
107
141
  return absPath;
108
142
  }
@@ -120,6 +154,8 @@ export async function resolveSsrPageEntries(opts: {
120
154
  const absPageDir = path.resolve(opts.appCwdPath, "page");
121
155
  const hasSt = await appHasStModule(opts.appCwdPath);
122
156
  const basePaths = opts.basePaths ?? [];
157
+ const rootSourceAbsPath = findExplicitRootLayoutAbsPath(opts.pageKeys, opts.appCwdPath);
158
+ const rootBoundaries = findRootBoundaries(opts.pageKeys, opts.appCwdPath, basePaths);
123
159
  const rootLayoutKeys = new Set(
124
160
  opts.pageKeys.filter((key) => {
125
161
  const segments = getRootBoundarySegments(key);
@@ -133,15 +169,17 @@ export async function resolveSsrPageEntries(opts: {
133
169
  moduleAbsPath: path.resolve(absPageDir, key),
134
170
  }));
135
171
  const generated = await Promise.all(
136
- findRootBoundaries(opts.pageKeys, opts.appCwdPath, basePaths).map(async (boundary) => ({
172
+ rootBoundaries.map(async (boundary) => ({
137
173
  key: implicitRootLayoutKey(boundary.segments),
138
174
  moduleAbsPath: await writeGeneratedRootLayoutFile({
139
175
  appCwdPath: opts.appCwdPath,
140
176
  appName: opts.appName,
141
177
  boundary,
178
+ rootSourceAbsPath,
142
179
  includeStInit: hasSt && boundary.segments.length === 0,
180
+ includeSystemProvider: !hasAncestorRootBoundary(boundary, rootBoundaries),
143
181
  }),
144
- seedAbsPaths: boundary.sourceAbsPath ? [boundary.sourceAbsPath] : [],
182
+ seedAbsPaths: [...new Set([boundary.sourceAbsPath, rootSourceAbsPath].filter((absPath) => absPath !== null))],
145
183
  })),
146
184
  );
147
185
  const entries = [...base, ...generated];
@@ -46,7 +46,7 @@ describe("computeRouteSeedIndex", () => {
46
46
  });
47
47
 
48
48
  test("serializes seed paths relative to the artifact directory", () => {
49
- const artifactDir = "/repo/dist/apps/angelo/.akan/artifact";
49
+ const artifactDir = "/repo/dist/apps/akan/.akan/artifact";
50
50
  const serialized = serializeRouteSeedIndexForArtifact(
51
51
  {
52
52
  entries: [
@@ -54,36 +54,36 @@ describe("computeRouteSeedIndex", () => {
54
54
  routeId: "/profile",
55
55
  pattern: "/profile",
56
56
  seeds: [
57
- "/repo/dist/apps/angelo/.akan/generated/implicit-root-layout.tsx",
58
- "/repo/apps/angelo/page/profile.tsx",
57
+ "/repo/dist/apps/akan/.akan/generated/implicit-root-layout.tsx",
58
+ "/repo/apps/akan/page/profile.tsx",
59
59
  ],
60
60
  },
61
61
  ],
62
- globalLayoutFiles: ["/repo/dist/apps/angelo/.akan/generated/implicit-root-layout.tsx"],
62
+ globalLayoutFiles: ["/repo/dist/apps/akan/.akan/generated/implicit-root-layout.tsx"],
63
63
  },
64
64
  artifactDir,
65
65
  );
66
66
 
67
67
  expect(serialized.entries[0]?.seeds).toEqual([
68
68
  "../generated/implicit-root-layout.tsx",
69
- "../../../../../apps/angelo/page/profile.tsx",
69
+ "../../../../../apps/akan/page/profile.tsx",
70
70
  ]);
71
71
  expect(serialized.globalLayoutFiles).toEqual(["../generated/implicit-root-layout.tsx"]);
72
72
  expect(JSON.stringify(serialized)).not.toContain("/repo/");
73
73
  });
74
74
 
75
75
  test("omits source seed metadata for production artifacts", () => {
76
- const artifactDir = "/repo/dist/apps/angelo/.akan/artifact";
76
+ const artifactDir = "/repo/dist/apps/akan/.akan/artifact";
77
77
  const serialized = serializeRouteSeedIndexForArtifact(
78
78
  {
79
79
  entries: [
80
80
  {
81
81
  routeId: "/:lang/profile",
82
82
  pattern: "/:lang/profile",
83
- seeds: ["/repo/apps/angelo/page/profile.tsx"],
83
+ seeds: ["/repo/apps/akan/page/profile.tsx"],
84
84
  },
85
85
  ],
86
- globalLayoutFiles: ["/repo/apps/angelo/.akan/generated/implicit-root-layout.tsx"],
86
+ globalLayoutFiles: ["/repo/apps/akan/.akan/generated/implicit-root-layout.tsx"],
87
87
  },
88
88
  artifactDir,
89
89
  { production: true },
@@ -0,0 +1,101 @@
1
+ interface AccessToken {
2
+ jwt: string;
3
+ refreshToken: string;
4
+ expiresAt: Date;
5
+ }
6
+
7
+ class HttpClient {
8
+ readonly baseUrl: string;
9
+ constructor(baseUrl: string) {
10
+ this.baseUrl = baseUrl;
11
+ }
12
+ async get<T>(
13
+ url: string,
14
+ { headers }: { headers?: Record<string, string> } = {},
15
+ ): Promise<T> {
16
+ const response = await fetch(`${this.baseUrl}${url}`, {
17
+ headers: { "Content-Type": "application/json", ...headers },
18
+ });
19
+ return response.json();
20
+ }
21
+ async post<T>(
22
+ url: string,
23
+ data: unknown,
24
+ { headers }: { headers?: Record<string, string> } = {},
25
+ ): Promise<T> {
26
+ const isFormData = data instanceof FormData;
27
+ const response = await fetch(`${this.baseUrl}${url}`, {
28
+ method: "POST",
29
+ body: isFormData ? data : JSON.stringify(data),
30
+ headers: isFormData
31
+ ? headers
32
+ : { "Content-Type": "application/json", ...headers },
33
+ });
34
+ return response.json();
35
+ }
36
+ }
37
+
38
+ export class CloudApi {
39
+ readonly api: HttpClient;
40
+ #accessToken: AccessToken | null = null;
41
+ constructor(
42
+ host: string,
43
+ { accessToken }: { accessToken?: AccessToken } = {},
44
+ ) {
45
+ this.api = new HttpClient(`${host}/api`);
46
+ this.#accessToken = accessToken ?? null;
47
+ }
48
+
49
+ async uploadEnv(
50
+ devProjectId: string,
51
+ fileStream: ReadableStream,
52
+ ): Promise<boolean> {
53
+ const formData = new FormData();
54
+ formData.append("devProjectId", devProjectId);
55
+ formData.append("fileStream", await new Response(fileStream).blob());
56
+ const response = await this.api.post<{ success: boolean }>(
57
+ `/uploadEnv/${devProjectId}`,
58
+ formData,
59
+ );
60
+ return response.success;
61
+ }
62
+ async downloadEnv(devProjectId: string): Promise<boolean> {
63
+ const response = await this.api.get<{ success: boolean }>(
64
+ `/downloadEnv/${devProjectId}`,
65
+ );
66
+ return response.success;
67
+ }
68
+ async getRemoteAuthToken(remoteId: string): Promise<AccessToken> {
69
+ if (this.#needRefreshToken()) return await this.refreshAuthToken();
70
+ else if (this.#accessToken) return this.#accessToken;
71
+ const accessToken = await this.api.get<AccessToken>(
72
+ `/getRemoteAuthToken/${remoteId}`,
73
+ );
74
+ this.#accessToken = {
75
+ jwt: accessToken.jwt,
76
+ refreshToken: accessToken.refreshToken,
77
+ expiresAt: new Date(accessToken.expiresAt),
78
+ };
79
+ return accessToken;
80
+ }
81
+ async refreshAuthToken(): Promise<AccessToken> {
82
+ const response = await this.api.post<AccessToken>(
83
+ `/refreshRemoteAuthToken`,
84
+ {
85
+ refreshToken: this.#accessToken?.refreshToken,
86
+ },
87
+ );
88
+ this.#accessToken = {
89
+ jwt: response.jwt,
90
+ refreshToken: response.refreshToken,
91
+ expiresAt: new Date(response.expiresAt),
92
+ };
93
+ return response;
94
+ }
95
+ #needRefreshToken(): boolean {
96
+ return !!(
97
+ this.#accessToken?.expiresAt &&
98
+ this.#accessToken.expiresAt.getTime() < Date.now() - 1000 * 60 * 60
99
+ );
100
+ }
101
+ }
package/cloud/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./cloudApi";
package/constants.ts CHANGED
@@ -3,8 +3,12 @@ import type { SupportedLlmModel } from "./aiEditor";
3
3
  export const basePath = `${Bun.env.HOME ?? Bun.env.USERPROFILE}/.akan`;
4
4
  export const configPath = `${basePath}/config.json`;
5
5
  export const akanCloudHost =
6
- process.env.AKAN_PUBLIC_OPERATION_MODE === "local" ? "http://localhost" : "https://cloud.akanjs.com";
7
- export const akanCloudUrl = `${akanCloudHost}${process.env.AKAN_PUBLIC_OPERATION_MODE === "local" ? ":8282" : ""}/메ㅑ`;
6
+ process.env.AKAN_PUBLIC_OPERATION_MODE === "local"
7
+ ? "http://localhost"
8
+ : "https://cloud.akanjs.com";
9
+ export const akanCloudUrl = `${akanCloudHost}${
10
+ process.env.AKAN_PUBLIC_OPERATION_MODE === "local" ? ":8282" : ""
11
+ }/api`;
8
12
 
9
13
  export interface HostConfig {
10
14
  auth?: {
@@ -22,4 +26,7 @@ export interface AkanGlobalConfig {
22
26
  apiKey: string;
23
27
  } | null;
24
28
  }
25
- export const defaultAkanGlobalConfig: AkanGlobalConfig = { cloudHost: {}, llm: null };
29
+ export const defaultAkanGlobalConfig: AkanGlobalConfig = {
30
+ cloudHost: {},
31
+ llm: null,
32
+ };
@@ -3,6 +3,7 @@ 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 { ApplicationBuildReporter } from "./applicationBuildReporter";
6
+ import { resolveSignalTestPreloadPath } from "./applicationTestPreload";
6
7
  import { TypeScriptDependencyScanner } from "./dependencyScanner";
7
8
  import { extractDependencies } from "./extractDeps";
8
9
  import { getModelFileData } from "./getModelFileData";
@@ -84,6 +85,26 @@ describe("extractDependencies", () => {
84
85
  });
85
86
  });
86
87
 
88
+ describe("resolveSignalTestPreloadPath", () => {
89
+ test("resolves the preload file from an installed akanjs package", async () => {
90
+ const root = await makeTempRoot();
91
+ const libDir = path.join(root, "libs/shared");
92
+ await write(
93
+ path.join(root, "node_modules/akanjs/package.json"),
94
+ JSON.stringify({
95
+ name: "akanjs",
96
+ version: "0.0.0",
97
+ exports: { "./package.json": "./package.json" },
98
+ }),
99
+ );
100
+ await write(path.join(root, "node_modules/akanjs/test/signalTest.preload.ts"), "export {};\n");
101
+
102
+ await expect(resolveSignalTestPreloadPath({ cwdPath: libDir })).resolves.toContain(
103
+ "node_modules/akanjs/test/signalTest.preload.ts",
104
+ );
105
+ });
106
+ });
107
+
87
108
  describe("TypeScriptDependencyScanner", () => {
88
109
  test("separates monorepo package, lib, runtime, and type-only dependencies", async () => {
89
110
  const root = await makeTempRoot();
package/executors.ts CHANGED
@@ -239,8 +239,6 @@ export class Executor {
239
239
  this.logger = new Logger(name);
240
240
  this.logs = [] as string[];
241
241
  this.cwdPath = cwdPath;
242
- //! TODO: 테스트 확인 필요
243
- // if (!(await Bun.file(cwdPath).exists())) await mkdir(cwdPath, { recursive: true });
244
242
  }
245
243
  #stdout(data: Buffer) {
246
244
  if (Executor.verbose) Logger.raw(chalk.dim(data.toString()));
@@ -29,14 +29,14 @@ describe("route client store bootstrap", () => {
29
29
  test("wraps client entries with app client bootstrap before re-exporting components", () => {
30
30
  const original = "/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx";
31
31
  const source = RouteClientBuilder.createStoreBootstrapEntrySource({
32
- appName: "angelo",
32
+ appName: "akan",
33
33
  originalEntry: original,
34
34
  exportNames: ["NewWrapper_Client", "default"],
35
35
  });
36
36
 
37
37
  expect(source).toBe(
38
38
  [
39
- 'import "@apps/angelo/client";',
39
+ 'import "@apps/akan/client";',
40
40
  `export { NewWrapper_Client } from ${JSON.stringify(original)};`,
41
41
  `export { default } from ${JSON.stringify(original)};`,
42
42
  "",
@@ -45,12 +45,12 @@ describe("route client store bootstrap", () => {
45
45
  });
46
46
 
47
47
  test("remaps wrapper manifest keys back to original client entry paths", () => {
48
- const wrapper = "/repo/apps/angelo/.akan/generated/client-entry-bootstrap/NewWrapper_Client.tsx";
48
+ const wrapper = "/repo/apps/akan/.akan/generated/client-entry-bootstrap/NewWrapper_Client.tsx";
49
49
  const original = "/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx";
50
50
  const remapped = RouteClientBuilder.resolveOriginalManifestEntry(
51
- "apps/angelo/.akan/generated/client-entry-bootstrap/NewWrapper_Client.tsx#NewWrapper_Client",
51
+ "apps/akan/.akan/generated/client-entry-bootstrap/NewWrapper_Client.tsx#NewWrapper_Client",
52
52
  new Map([[wrapper, original]]),
53
- new Map([[wrapper, "apps/angelo/.akan/generated/client-entry-bootstrap/NewWrapper_Client.tsx"]]),
53
+ new Map([[wrapper, "apps/akan/.akan/generated/client-entry-bootstrap/NewWrapper_Client.tsx"]]),
54
54
  "/repo",
55
55
  );
56
56
 
@@ -10,6 +10,7 @@ import { CssImportResolver } from "./cssImportResolver";
10
10
  import { HmrChangeClassifier } from "./hmrChangeClassifier";
11
11
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
12
12
  import { RoutesManifestArtifactSerializer } from "./routesManifestArtifactSerializer";
13
+ import { prepareCssAsset } from "./ssrBaseArtifactBuilder";
13
14
 
14
15
  const tempRoots: string[] = [];
15
16
 
@@ -97,6 +98,32 @@ describe("CsrArtifactBuilder", () => {
97
98
  });
98
99
  });
99
100
 
101
+ describe("SsrBaseArtifactBuilder", () => {
102
+ test("minifies CSS assets only for production builds", async () => {
103
+ const css = [
104
+ ".card {",
105
+ " color: red;",
106
+ " padding: 1rem;",
107
+ "}",
108
+ "",
109
+ "@media (width >= 768px) {",
110
+ " .card {",
111
+ " color: blue;",
112
+ " }",
113
+ "}",
114
+ "",
115
+ ].join("\n");
116
+
117
+ const development = await prepareCssAsset("start", "", css);
118
+ const production = await prepareCssAsset("build", "", css);
119
+
120
+ expect(development).toBe(css);
121
+ expect(production.length).toBeLessThan(css.length);
122
+ expect(production).toContain(".card{");
123
+ expect(production).not.toContain("\n ");
124
+ });
125
+ });
126
+
100
127
  describe("RoutesManifestArtifactSerializer", () => {
101
128
  test("serializes absolute artifact paths relative to artifact directory", () => {
102
129
  const manifest = {
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { optimize } from "@tailwindcss/node";
2
3
  import type { BaseBuildArtifact } from "akanjs/server";
3
4
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
4
5
  import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
@@ -16,6 +17,11 @@ export interface BuildSsrBaseArtifactResult {
16
17
  optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
17
18
  }
18
19
 
20
+ export function prepareCssAsset(command: "build" | "start", basePath: string, cssText: string): string {
21
+ if (command !== "build") return cssText;
22
+ return optimize(cssText, { file: `${basePath || "root"}.css`, minify: true }).code;
23
+ }
24
+
19
25
  export class SsrBaseArtifactBuilder {
20
26
  #app: App;
21
27
  #command: "build" | "start";
@@ -143,13 +149,14 @@ export class SsrBaseArtifactBuilder {
143
149
 
144
150
  async #writeCssAsset(basePath: string, cssText: string) {
145
151
  const cssAssetName = basePath || "root";
146
- const cssHash = Bun.hash(`${basePath}\n${cssText}`).toString(36);
152
+ const preparedCssText = await prepareCssAsset(this.#command, basePath, cssText);
153
+ const cssHash = Bun.hash(`${basePath}\n${preparedCssText}`).toString(36);
147
154
  const [cssRelPath, cssUrl] = [
148
155
  `styles/${cssAssetName}-${cssHash}.css`,
149
156
  `/_akan/styles/${cssAssetName}-${cssHash}.css`,
150
157
  ];
151
- await Bun.write(path.join(this.#absArtifactDir, cssRelPath), cssText);
152
- this.#app.verbose(`[base-artifact] wrote ${cssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
158
+ await Bun.write(path.join(this.#absArtifactDir, cssRelPath), preparedCssText);
159
+ this.#app.verbose(`[base-artifact] wrote ${preparedCssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
153
160
  return [basePath, { cssUrl, cssRelPath }] as const;
154
161
  }
155
162
  }
package/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./akanConfig";
4
4
  export * from "./applicationBuildReporter";
5
5
  export * from "./applicationBuildRunner";
6
6
  export * from "./applicationReleasePackager";
7
+ export * from "./applicationTestPreload";
7
8
  export * from "./artifact";
8
9
  export * from "./auth";
9
10
  export * from "./builder";
@@ -34,3 +35,4 @@ export * from "./types";
34
35
  export * from "./ui";
35
36
  export * from "./uploadRelease";
36
37
  export * from "./useStdoutDimensions";
38
+ export * from "./cloud";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.1.0-rc.9",
3
+ "version": "2.1.1-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -30,8 +30,9 @@
30
30
  "@langchain/core": "^1.1.47",
31
31
  "@langchain/deepseek": "^1.0.26",
32
32
  "@langchain/openai": "^1.4.6",
33
+ "@tailwindcss/node": "^4.3.0",
33
34
  "@trapezedev/project": "^7.1.4",
34
- "akanjs": "2.1.0-rc.8",
35
+ "akanjs": "2.1.1-rc.0",
35
36
  "chalk": "^5.6.2",
36
37
  "commander": "^14.0.3",
37
38
  "daisyui": "^5.5.20",