@akanjs/devkit 2.3.9 → 2.3.10-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.
@@ -281,11 +281,17 @@ describe("AkanAppConfig", () => {
281
281
  targets: {
282
282
  admin: {
283
283
  basePath: "admin",
284
+ indexPath: "/admin/home/",
284
285
  appName: "Portal Admin",
285
286
  appId: "com.portal.admin",
286
287
  buildNum: 8,
287
288
  permissions: ["camera"],
288
- links: { schemes: ["portal-admin"] },
289
+ deepLinks: {
290
+ schemes: ["portal-admin", "portal-admin"],
291
+ domains: ["https://Portal.Admin/"],
292
+ ios: { teamId: " TEAMID " },
293
+ android: { sha256CertFingerprints: ["AA:BB", "AA:BB"] },
294
+ },
289
295
  },
290
296
  },
291
297
  },
@@ -296,12 +302,18 @@ describe("AkanAppConfig", () => {
296
302
  expect(config.mobile.targets.admin).toMatchObject({
297
303
  name: "admin",
298
304
  basePath: "admin",
305
+ indexPath: "/admin/home",
299
306
  appName: "Portal Admin",
300
307
  appId: "com.portal.admin",
301
308
  version: "1.0.0",
302
309
  buildNum: 8,
303
310
  permissions: ["camera"],
304
- links: { schemes: ["portal-admin"] },
311
+ deepLinks: {
312
+ schemes: ["portal-admin"],
313
+ domains: ["portal.admin"],
314
+ ios: { teamId: "TEAMID" },
315
+ android: { sha256CertFingerprints: ["AA:BB"] },
316
+ },
305
317
  });
306
318
 
307
319
  expect(
@@ -76,6 +76,47 @@ const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
76
76
  maxRemoteBytes: 25 * 1024 * 1024,
77
77
  };
78
78
 
79
+ const normalizeIndexPath = (indexPath: string | undefined): string | undefined => {
80
+ const normalized = indexPath?.trim();
81
+ if (!normalized) return undefined;
82
+ const path = `/${normalized.replace(/^\/+|\/+$/g, "")}`;
83
+ return path === "/" ? "/" : path;
84
+ };
85
+
86
+ const normalizeStringList = (values: string[] | undefined) => {
87
+ const normalized = values?.map((value) => value.trim()).filter(Boolean) ?? [];
88
+ return normalized.length > 0 ? [...new Set(normalized)] : undefined;
89
+ };
90
+
91
+ const normalizeDeepLinkDomain = (domain: string) => {
92
+ const normalized = domain.trim();
93
+ if (!normalized) return "";
94
+ try {
95
+ const url = new URL(normalized.includes("://") ? normalized : `https://${normalized}`);
96
+ return url.host.toLowerCase();
97
+ } catch {
98
+ return normalized
99
+ .replace(/^https?:\/\//, "")
100
+ .replace(/\/+$/g, "")
101
+ .toLowerCase();
102
+ }
103
+ };
104
+
105
+ const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepLinks"]> | undefined) => {
106
+ if (!deepLinks) return undefined;
107
+ const schemes = normalizeStringList(deepLinks.schemes as string[] | undefined);
108
+ const domains = normalizeStringList((deepLinks.domains as string[] | undefined)?.map(normalizeDeepLinkDomain));
109
+ const teamId = (deepLinks.ios?.teamId as string | undefined)?.trim();
110
+ const sha256CertFingerprints = normalizeStringList(deepLinks.android?.sha256CertFingerprints as string[] | undefined);
111
+ if (!schemes && !domains && !teamId && !sha256CertFingerprints) return undefined;
112
+ return {
113
+ ...(schemes ? { schemes } : {}),
114
+ ...(domains ? { domains } : {}),
115
+ ...(teamId ? { ios: { teamId } } : {}),
116
+ ...(sha256CertFingerprints ? { android: { sha256CertFingerprints } } : {}),
117
+ } satisfies AkanMobileTargetConfig["deepLinks"];
118
+ };
119
+
79
120
  export class AkanAppConfig implements AppConfigResult {
80
121
  app: App;
81
122
  rootPackageJson: PackageJson;
@@ -88,6 +129,7 @@ export class AkanAppConfig implements AppConfigResult {
88
129
  i18n: AkanI18nConfig;
89
130
  publicEnv: string[];
90
131
  mobile: AkanMobileConfig;
132
+ secrets: string[];
91
133
  baseDevEnv: BaseDevEnv;
92
134
  libs: string[];
93
135
  domains = new Set<string>();
@@ -120,11 +162,18 @@ export class AkanAppConfig implements AppConfigResult {
120
162
  process.env.AKAN_PUBLIC_DEFAULT_LOCALE = this.i18n.defaultLocale;
121
163
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
122
164
  this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
165
+ this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
123
166
  this.mobile = this.#resolveMobileConfig(config.mobile);
124
167
  this.docker = this.#makeDockerContent(config?.docker ?? {});
125
168
  }
126
169
  #resolveMobileConfig(mobile: DeepPartial<AkanMobileConfig> | undefined): AkanMobileConfig {
127
- const { targets: rawTargets, ...rawMobile } = mobile ?? {};
170
+ const {
171
+ targets: rawTargets,
172
+ indexPath: _indexPath,
173
+ ...rawMobile
174
+ } = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & {
175
+ indexPath?: unknown;
176
+ };
128
177
  const appName = rawMobile.appName ?? this.app.name;
129
178
  const appId = rawMobile.appId ?? `com.${this.app.name}.app`;
130
179
  const version = rawMobile.version ?? "0.0.1";
@@ -140,6 +189,8 @@ export class AkanAppConfig implements AppConfigResult {
140
189
  const target = rawTarget as DeepPartial<AkanMobileTargetConfig>;
141
190
  const fallbackBasePath = !rawTargets && this.basePaths.has(name) ? name : undefined;
142
191
  const basePath = (target.basePath ?? fallbackBasePath)?.replace(/^\/+|\/+$/g, "") || undefined;
192
+ const indexPath = normalizeIndexPath(target.indexPath as string | undefined);
193
+ const deepLinks = normalizeDeepLinks(target.deepLinks);
143
194
  if (basePath && !this.basePaths.has(basePath)) {
144
195
  throw new Error(
145
196
  `Mobile target '${name}' uses unknown basePath '${basePath}' in apps/${this.app.name}/akan.config.ts`,
@@ -150,6 +201,8 @@ export class AkanAppConfig implements AppConfigResult {
150
201
  ...target,
151
202
  name,
152
203
  basePath,
204
+ indexPath,
205
+ deepLinks,
153
206
  appName: target.appName ?? appName,
154
207
  appId: target.appId ?? appId,
155
208
  version: target.version ?? version,
@@ -34,9 +34,10 @@ const baseTarget: AkanMobileTargetConfig = {
34
34
  version: "1.2.3",
35
35
  buildNum: 7,
36
36
  basePath: "admin",
37
+ indexPath: "/dashboard",
37
38
  assets: { icon: "mobile/icon.png" },
38
39
  permissions: ["camera"],
39
- links: { schemes: ["minimal"] },
40
+ deepLinks: { schemes: ["minimal"], domains: ["minimal.app"] },
40
41
  files: { android: { "app/google-services.json": "private/google-services.json" } },
41
42
  };
42
43
 
@@ -74,11 +75,12 @@ describe("materializeCapacitorConfig", () => {
74
75
  });
75
76
  expect(config).not.toHaveProperty("name");
76
77
  expect(config).not.toHaveProperty("basePath");
78
+ expect(config).not.toHaveProperty("indexPath");
77
79
  expect(config).not.toHaveProperty("version");
78
80
  expect(config).not.toHaveProperty("buildNum");
79
81
  expect(config).not.toHaveProperty("assets");
80
82
  expect(config).not.toHaveProperty("permissions");
81
- expect(config).not.toHaveProperty("links");
83
+ expect(config).not.toHaveProperty("deepLinks");
82
84
  expect(config).not.toHaveProperty("files");
83
85
  expect(config).not.toHaveProperty("server");
84
86
  });
package/capacitorApp.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, rm } from "node:fs/promises";
1
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { CapacitorConfig } from "@capacitor/cli";
@@ -463,11 +463,12 @@ export function materializeCapacitorConfig(
463
463
  const {
464
464
  name,
465
465
  basePath: _basePath,
466
+ indexPath: _indexPath,
466
467
  version: _version,
467
468
  buildNum: _buildNum,
468
469
  assets: _assets,
469
470
  permissions: _permissions,
470
- links: _links,
471
+ deepLinks: _deepLinks,
471
472
  files: _files,
472
473
  appId,
473
474
  appName,
@@ -587,7 +588,7 @@ export class CapacitorApp {
587
588
  await this.#prepareExternalFiles("ios");
588
589
  await this.#applyIosMetadata();
589
590
  await this.#applyPermissions();
590
- await this.#applyLinks();
591
+ await this.#applyDeepLinks("ios", { operation, env });
591
592
  await this.project.commit();
592
593
  await this.#generateAssets({ operation, env });
593
594
  this.app.verbose(`syncing iOS`);
@@ -749,12 +750,13 @@ export class CapacitorApp {
749
750
  await this.#prepareExternalFiles("android");
750
751
  await this.#applyAndroidMetadata();
751
752
  await this.#applyPermissions();
752
- await this.#applyLinks();
753
+ await this.#applyDeepLinks("android", { operation, env });
753
754
  await this.project.commit();
754
755
  await this.#generateAssets({ operation, env });
755
756
  await this.#ensureAndroidAssetsDir();
756
757
  await this.#ensureAndroidDebugKeystore();
757
758
  await this.#spawnMobile("npx", ["cap", "sync", "android"], { operation, env });
759
+ await this.#setDeepLinksInAndroid(this.target.deepLinks?.schemes ?? [], this.target.deepLinks?.domains ?? []);
758
760
  }
759
761
 
760
762
  async #updateAndroidBuildTypes() {
@@ -898,7 +900,11 @@ export class CapacitorApp {
898
900
  }
899
901
  #injectMobileTargetMeta(html: string) {
900
902
  const basePath = this.target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
901
- const script = `<script>window.__AKAN_MOBILE_TARGET__=${JSON.stringify({ name: this.target.name, basePath })};</script>`;
903
+ const script = `<script>window.__AKAN_MOBILE_TARGET__=${JSON.stringify({
904
+ name: this.target.name,
905
+ basePath,
906
+ indexPath: this.target.indexPath,
907
+ })};</script>`;
902
908
  if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
903
909
  return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
904
910
  }
@@ -977,14 +983,23 @@ export class CapacitorApp {
977
983
  else if (permission === "push") await this.addPush();
978
984
  }
979
985
  }
980
- async #applyLinks() {
981
- const links = this.target.links;
982
- if (!links) return;
983
- const schemes = links.schemes ?? [];
984
- if (schemes.length > 0) {
985
- await this.#setPermissionInIos({
986
- appTransportSecurity: "",
987
- });
986
+ async #applyDeepLinks(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
987
+ const deepLinks = this.target.deepLinks;
988
+ if (!deepLinks) return;
989
+ const schemes = deepLinks.schemes ?? [];
990
+ const domains = deepLinks.domains ?? [];
991
+ if (domains.length > 0) this.#assertDeepLinkVerificationConfig(platform, { operation, env });
992
+ if (platform === "ios") {
993
+ if (schemes.length > 0) {
994
+ await this.#setPermissionInIos({
995
+ appTransportSecurity: "",
996
+ });
997
+ await this.#setUrlSchemesInIos(schemes);
998
+ }
999
+ if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains);
1000
+ return;
1001
+ }
1002
+ if (platform === "android") {
988
1003
  for (const scheme of schemes) {
989
1004
  this.project.android
990
1005
  .getAndroidManifest()
@@ -993,18 +1008,30 @@ export class CapacitorApp {
993
1008
  `<intent-filter><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="${scheme}" /></intent-filter>`,
994
1009
  );
995
1010
  }
1011
+ for (const domain of domains) {
1012
+ const pathPrefix = resolveMobilePath(this.target, "/");
1013
+ this.project.android
1014
+ .getAndroidManifest()
1015
+ .injectFragment(
1016
+ "activity",
1017
+ `<intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" android:host="${domain}" android:pathPrefix="${pathPrefix}" /></intent-filter>`,
1018
+ );
1019
+ }
1020
+ await this.#setDeepLinksInAndroid(schemes, domains);
996
1021
  }
997
- for (const domain of links.associatedDomains ?? []) {
998
- this.app.logger.info(`Configure iOS associated domain manually if needed: ${domain}`);
1022
+ }
1023
+ #assertDeepLinkVerificationConfig(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
1024
+ const deepLinks = this.target.deepLinks;
1025
+ if (!deepLinks?.domains?.length) return;
1026
+ if (platform === "ios" && !deepLinks.ios?.teamId) {
1027
+ throw new Error(
1028
+ `Mobile target '${this.target.name}' uses deepLinks.domains but is missing deepLinks.ios.teamId in apps/${this.app.name}/akan.config.ts`,
1029
+ );
999
1030
  }
1000
- for (const host of links.androidHosts ?? []) {
1001
- const pathPrefix = resolveMobilePath(this.target, "/");
1002
- this.project.android
1003
- .getAndroidManifest()
1004
- .injectFragment(
1005
- "activity",
1006
- `<intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" android:host="${host}" android:pathPrefix="${pathPrefix}" /></intent-filter>`,
1007
- );
1031
+ if (platform === "android" && !deepLinks.android?.sha256CertFingerprints?.length) {
1032
+ const message = `Mobile target '${this.target.name}' uses deepLinks.domains but is missing deepLinks.android.sha256CertFingerprints in apps/${this.app.name}/akan.config.ts`;
1033
+ if (operation === "release" || env === "main") throw new Error(message);
1034
+ this.app.logger.warn(message);
1008
1035
  }
1009
1036
  }
1010
1037
  async #commandEnv(operation: "local" | "release", env: "local" | "debug" | "develop" | "main") {
@@ -1024,6 +1051,7 @@ export class CapacitorApp {
1024
1051
  const port = commandEnv.AKAN_PUBLIC_CLIENT_PORT ?? commandEnv.PORT ?? "8282";
1025
1052
  const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
1026
1053
  if (basePath) params.set("akanMobileBasePath", basePath);
1054
+ if (this.target.indexPath) params.set("akanMobileIndexPath", this.target.indexPath);
1027
1055
  return `http://${ip}:${port}/${pathname}?${params}`;
1028
1056
  }
1029
1057
  async #clearRootCapacitorConfigs() {
@@ -1090,6 +1118,96 @@ export class CapacitorApp {
1090
1118
  this.project.ios.updateInfoPlist(this.iosTargetName, "Release", updateNs),
1091
1119
  ]);
1092
1120
  }
1121
+ async #setUrlSchemesInIos(schemes: string[]) {
1122
+ const urlTypes = schemes.map((scheme) => ({
1123
+ CFBundleURLName: this.target.appId,
1124
+ CFBundleURLSchemes: [scheme],
1125
+ }));
1126
+ await Promise.all([
1127
+ this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", { CFBundleURLTypes: urlTypes }),
1128
+ this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { CFBundleURLTypes: urlTypes }),
1129
+ ]);
1130
+ }
1131
+ async #setAssociatedDomainsInIos(domains: string[]) {
1132
+ const entitlementsRelPath = "App/App.entitlements";
1133
+ const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
1134
+ const values = domains.map((domain) => `applinks:${domain}`);
1135
+ const body = [
1136
+ '<?xml version="1.0" encoding="UTF-8"?>',
1137
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1138
+ '<plist version="1.0">',
1139
+ "<dict>",
1140
+ " <key>com.apple.developer.associated-domains</key>",
1141
+ " <array>",
1142
+ ...values.map((value) => ` <string>${value}</string>`),
1143
+ " </array>",
1144
+ "</dict>",
1145
+ "</plist>",
1146
+ "",
1147
+ ].join("\n");
1148
+ await writeFile(entitlementsPath, body);
1149
+ await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
1150
+ }
1151
+ async #setCodeSignEntitlementsInIos(entitlementsRelPath: string) {
1152
+ const pbxprojPath = path.join(this.app.cwdPath, this.iosProjectPath, "App.xcodeproj/project.pbxproj");
1153
+ const lines = (await readFile(pbxprojPath, "utf8")).split("\n");
1154
+ let changed = false;
1155
+ for (let index = 0; index < lines.length; index++) {
1156
+ const line = lines[index] ?? "";
1157
+ if (!line.includes(`PRODUCT_BUNDLE_IDENTIFIER = ${this.target.appId};`)) continue;
1158
+ let start = index;
1159
+ while (start >= 0 && !lines[start]?.includes("buildSettings = {")) start--;
1160
+ let end = index;
1161
+ while (end < lines.length && !/^\s*\};\s*$/.test(lines[end] ?? "")) end++;
1162
+ const settings = lines.slice(start, end + 1);
1163
+ if (settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) continue;
1164
+ const indent = line.match(/^\s*/)?.[0] ?? "";
1165
+ lines.splice(index, 0, `${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1166
+ index++;
1167
+ changed = true;
1168
+ }
1169
+ if (changed) await writeFile(pbxprojPath, lines.join("\n"));
1170
+ }
1171
+ async #setUrlSchemesInAndroid(schemes: string[]) {
1172
+ const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1173
+ let manifest = await readFile(manifestPath, "utf8");
1174
+ let changed = false;
1175
+ for (const scheme of schemes) {
1176
+ if (manifest.includes(`android:scheme="${scheme}"`)) continue;
1177
+ const filter = [
1178
+ " <intent-filter>",
1179
+ ' <action android:name="android.intent.action.VIEW" />',
1180
+ ' <category android:name="android.intent.category.DEFAULT" />',
1181
+ ' <category android:name="android.intent.category.BROWSABLE" />',
1182
+ ` <data android:scheme="${scheme}" />`,
1183
+ " </intent-filter>",
1184
+ ].join("\n");
1185
+ manifest = manifest.replace(/(\s*<\/activity>)/, `\n${filter}$1`);
1186
+ changed = true;
1187
+ }
1188
+ if (changed) await writeFile(manifestPath, manifest);
1189
+ }
1190
+ async #setDeepLinksInAndroid(schemes: string[], domains: string[]) {
1191
+ await this.#setUrlSchemesInAndroid(schemes);
1192
+ const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1193
+ let manifest = await readFile(manifestPath, "utf8");
1194
+ let changed = false;
1195
+ const pathPrefix = resolveMobilePath(this.target, "/");
1196
+ for (const domain of domains) {
1197
+ if (manifest.includes(`android:host="${domain}"`) && manifest.includes('android:scheme="https"')) continue;
1198
+ const filter = [
1199
+ ' <intent-filter android:autoVerify="true">',
1200
+ ' <action android:name="android.intent.action.VIEW" />',
1201
+ ' <category android:name="android.intent.category.DEFAULT" />',
1202
+ ' <category android:name="android.intent.category.BROWSABLE" />',
1203
+ ` <data android:scheme="https" android:host="${domain}" android:pathPrefix="${pathPrefix}" />`,
1204
+ " </intent-filter>",
1205
+ ].join("\n");
1206
+ manifest = manifest.replace(/(\s*<\/activity>)/, `\n${filter}$1`);
1207
+ changed = true;
1208
+ }
1209
+ if (changed) await writeFile(manifestPath, manifest);
1210
+ }
1093
1211
  #setFeaturesInAndroid(features: string[]) {
1094
1212
  for (const feature of features) {
1095
1213
  if (this.#hasFeatureInAndroid(feature)) {
package/executors.ts CHANGED
@@ -437,8 +437,11 @@ export class Executor {
437
437
  getPath(filePath: string) {
438
438
  if (path.isAbsolute(filePath)) return filePath;
439
439
  if (filePath.startsWith(".")) return path.join(this.cwdPath, filePath);
440
- const baseParts = this.cwdPath.split("/").filter(Boolean);
441
- const targetParts = filePath.split("/").filter(Boolean);
440
+ // Split on both separators so a Windows/mixed cwdPath (e.g. "C:\repo/apps/name") is parsed
441
+ // correctly, then rebuild with path.join so the result stays OS-native instead of the
442
+ // invalid "/C:\repo/..." the previous forward-slash-only reconstruction produced.
443
+ const baseParts = this.cwdPath.split(/[\\/]/).filter(Boolean);
444
+ const targetParts = filePath.split(/[\\/]/).filter(Boolean);
442
445
 
443
446
  let overlapLength = 0;
444
447
  for (let i = 1; i <= Math.min(baseParts.length, targetParts.length); i++) {
@@ -450,11 +453,7 @@ export class Executor {
450
453
  }
451
454
  if (isOverlap) overlapLength = i;
452
455
  }
453
- const result =
454
- overlapLength > 0
455
- ? `/${[...baseParts, ...targetParts.slice(overlapLength)].join("/")}`
456
- : `${this.cwdPath}/${filePath}`;
457
- return result.replace(/\/+/g, "/");
456
+ return path.join(this.cwdPath, ...targetParts.slice(overlapLength));
458
457
  }
459
458
  async mkdir(dirPath: string) {
460
459
  const writePath = this.getPath(dirPath);
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { fileURLToPath } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import type { RoutesManifest } from "akanjs/server";
7
7
  import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
8
  import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
@@ -33,17 +33,19 @@ afterEach(async () => {
33
33
  });
34
34
 
35
35
  describe("PagesEntrySourceGenerator", () => {
36
- test("generates dynamic import source using absolute module paths", () => {
36
+ test("generates dynamic import source using file:// module URLs", () => {
37
+ const indexAbs = path.resolve("/repo/apps/demo/page/_index.tsx");
38
+ const adminAbs = path.resolve("/repo/apps/demo/page/admin.tsx");
37
39
  const source = PagesEntrySourceGenerator.generate([
38
- { key: "./_index.tsx", moduleAbsPath: "/repo/apps/demo/page/_index.tsx" },
39
- { key: "./admin.tsx", moduleAbsPath: "/repo/apps/demo/page/admin.tsx" },
40
+ { key: "./_index.tsx", moduleAbsPath: indexAbs },
41
+ { key: "./admin.tsx", moduleAbsPath: adminAbs },
40
42
  ]);
41
43
 
42
44
  expect(source).toBe(
43
45
  [
44
46
  "export const pages = {",
45
- ' "./_index.tsx": () => import("/repo/apps/demo/page/_index.tsx"),',
46
- ' "./admin.tsx": () => import("/repo/apps/demo/page/admin.tsx"),',
47
+ ` "./_index.tsx": () => import(${JSON.stringify(pathToFileURL(indexAbs).href)}),`,
48
+ ` "./admin.tsx": () => import(${JSON.stringify(pathToFileURL(adminAbs).href)}),`,
47
49
  "};",
48
50
  "",
49
51
  ].join("\n"),
@@ -51,15 +53,17 @@ describe("PagesEntrySourceGenerator", () => {
51
53
  });
52
54
 
53
55
  test("generates static import source for single-file CSR bundles", () => {
56
+ const indexAbs = path.resolve("/repo/apps/demo/page/_index.tsx");
57
+ const adminAbs = path.resolve("/repo/apps/demo/page/admin.tsx");
54
58
  const source = PagesEntrySourceGenerator.generateStatic([
55
- { key: "./_index.tsx", moduleAbsPath: "/repo/apps/demo/page/_index.tsx" },
56
- { key: "./admin.tsx", moduleAbsPath: "/repo/apps/demo/page/admin.tsx" },
59
+ { key: "./_index.tsx", moduleAbsPath: indexAbs },
60
+ { key: "./admin.tsx", moduleAbsPath: adminAbs },
57
61
  ]);
58
62
 
59
63
  expect(source).toBe(
60
64
  [
61
- 'import * as page0 from "/repo/apps/demo/page/_index.tsx";',
62
- 'import * as page1 from "/repo/apps/demo/page/admin.tsx";',
65
+ `import * as page0 from ${JSON.stringify(pathToFileURL(indexAbs).href)};`,
66
+ `import * as page1 from ${JSON.stringify(pathToFileURL(adminAbs).href)};`,
63
67
  "export const pages = {",
64
68
  ' "./_index.tsx": { loader: async () => page0, isAsyncDefault: false },',
65
69
  ' "./admin.tsx": { loader: async () => page1, isAsyncDefault: false },',
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
3
4
  import ts from "typescript";
4
5
  import type { PageEntry } from "../artifact/implicitRootLayout";
5
6
 
@@ -16,8 +17,8 @@ export class PagesEntrySourceGenerator {
16
17
 
17
18
  generate(): string {
18
19
  const lines = this.#pageEntries.map(({ key, moduleAbsPath }) => {
19
- const absPath = path.resolve(moduleAbsPath);
20
- return ` ${JSON.stringify(key)}: () => import(${JSON.stringify(absPath)}),`;
20
+ const specifier = pathToFileURL(path.resolve(moduleAbsPath)).href;
21
+ return ` ${JSON.stringify(key)}: () => import(${JSON.stringify(specifier)}),`;
21
22
  });
22
23
  return `export const pages = {\n${lines.join("\n")}\n};\n`;
23
24
  }
@@ -28,8 +29,10 @@ export class PagesEntrySourceGenerator {
28
29
 
29
30
  generateStatic(): string {
30
31
  const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
31
- const absPath = path.resolve(moduleAbsPath);
32
- return `import * as page${index} from ${JSON.stringify(absPath)};`;
32
+ // Emit a file:// URL so the specifier is separator-agnostic; a raw Windows path
33
+ // (backslashes, or mixed with forward slashes) is a fragile ESM import specifier.
34
+ const specifier = pathToFileURL(path.resolve(moduleAbsPath)).href;
35
+ return `import * as page${index} from ${JSON.stringify(specifier)};`;
33
36
  });
34
37
  const entries = this.#pageEntries.map(({ key, moduleAbsPath }, index) => {
35
38
  const isAsyncDefault = PagesEntrySourceGenerator.#hasAsyncDefaultExport(moduleAbsPath);
@@ -77,6 +77,15 @@ export class SsrBaseArtifactBuilder {
77
77
  branches: [...akanConfig.branches],
78
78
  i18n: akanConfig.i18n,
79
79
  imageConfig: akanConfig.images,
80
+ deepLinkAssociations: Object.values(akanConfig.mobile.targets)
81
+ .filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
82
+ .map((target) => ({
83
+ targetName: target.name,
84
+ appId: target.appId,
85
+ domains: target.deepLinks?.domains ?? [],
86
+ iosTeamId: target.deepLinks?.ios?.teamId,
87
+ androidSha256CertFingerprints: target.deepLinks?.android?.sha256CertFingerprints,
88
+ })),
80
89
  };
81
90
  await Bun.write(path.join(this.#absArtifactDir, "base-artifact.json"), `${JSON.stringify(artifact, null, 2)}\n`);
82
91
  this.#app.verbose(`[base-artifact] complete in ${Date.now() - this.#started}ms`);
@@ -90,11 +99,15 @@ export class SsrBaseArtifactBuilder {
90
99
  }
91
100
  > {
92
101
  const akanServerPath = await this.#resolveAkanServerPath();
93
- const rscClientEntry = `${akanServerPath}/rscClient.tsx`;
94
- const rscSegmentOutletEntry = `${akanServerPath}/rscSegmentOutlet.tsx`;
102
+ // Normalize entry paths with path.resolve so they match the resolved map keys the bundler
103
+ // stores (see ClientEntriesBundler#createOpaqueEntryAliases). Raw string concatenation keeps
104
+ // forward slashes, which on Windows fail to match the `\`-separated resolved keys and silently
105
+ // yield empty import-map URLs, breaking hydration.
106
+ const rscClientEntry = path.resolve(akanServerPath, "rscClient.tsx");
107
+ const rscSegmentOutletEntry = path.resolve(akanServerPath, "rscSegmentOutlet.tsx");
95
108
  const vendorEntries = VENDOR_SPECIFIERS.map((specifier) => ({
96
109
  specifier,
97
- absPath: `${akanServerPath}/vendor/${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`,
110
+ absPath: path.resolve(akanServerPath, "vendor", `${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`),
98
111
  }));
99
112
  const entries = [rscClientEntry, rscSegmentOutletEntry, ...vendorEntries.map((v) => v.absPath)];
100
113
  const clientBundle = await new ClientEntriesBundler({ app: this.#app, entries, command: this.#command }).bundle();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.9",
3
+ "version": "2.3.10-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.9",
35
+ "akanjs": "2.3.10-rc.1",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",