@akanjs/devkit 2.3.12 → 2.3.13-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.
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { PackageJson } from "../types";
6
6
  import { AkanAppConfig, AkanLibConfig } from "./akanConfig";
7
- import type { DeepPartial, LibConfigResult } from "./types";
8
7
 
9
8
  const akanPackageJson = JSON.parse(
10
9
  fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import type { AkanPlugin } from "akanjs";
4
5
  import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
5
6
  import type { AkanImageConfig } from "akanjs/server";
6
7
  import type { App, Lib } from "../commandDecorators";
@@ -136,6 +137,8 @@ export class AkanAppConfig implements AppConfigResult {
136
137
  secrets: string[];
137
138
  baseDevEnv: BaseDevEnv;
138
139
  libs: string[];
140
+ /** Live-only: plugins declared in this app's `akan.config.ts` (never serialized). */
141
+ plugins: AkanPlugin[];
139
142
  domains = new Set<string>();
140
143
  subRoutes = new Map<string, Set<string>>();
141
144
  basePaths = new Set<string>();
@@ -146,11 +149,13 @@ export class AkanAppConfig implements AppConfigResult {
146
149
  rootPackageJson: PackageJson,
147
150
  config: DeepPartial<AppConfigResult>,
148
151
  baseDevEnv: BaseDevEnv,
152
+ plugins: AkanPlugin[] = [],
149
153
  ) {
150
154
  this.app = app;
151
155
  this.rootPackageJson = rootPackageJson;
152
156
  this.libs = libs;
153
157
  this.baseDevEnv = baseDevEnv;
158
+ this.plugins = plugins;
154
159
  this.#applyRoutes(config?.routes);
155
160
  this.defaultDatabaseMode = config?.defaultDatabaseMode ?? "single";
156
161
  this.externalLibs = config?.externalLibs ?? [];
@@ -341,15 +346,18 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
341
346
  app.workspace.getLibs(),
342
347
  app.workspace.getPackageJson(),
343
348
  ]);
344
- const config = typeof configImp === "function" ? configImp(app) : configImp;
345
- return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv);
349
+ const resolved = typeof configImp === "function" ? configImp(app) : configImp;
350
+ const { plugins, ...config } = (resolved ?? {}) as DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
351
+ return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? []);
346
352
  }
347
353
  #resolveProductionDependencyVersion(lib: string) {
348
354
  const rootVersion = this.rootPackageJson.dependencies?.[lib] ?? this.rootPackageJson.devDependencies?.[lib];
349
355
  if (rootVersion) return rootVersion;
356
+ // Fall back to the framework package's own (peer)dependencies so plugin-declared runtime
357
+ // packages (e.g. firebase for the push plugin) resolve a version without the framework
358
+ // hardcoding a per-feature package list.
350
359
  const akanPackageJson = getAkanPackageJson();
351
- if (AKAN_RUNTIME_PACKAGES.has(lib))
352
- return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
360
+ return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
353
361
  }
354
362
  #getProductionRuntimePackages() {
355
363
  return [
@@ -451,14 +459,18 @@ function mergeImageConfig(config: Partial<AkanImageConfig> = {}): AkanImageConfi
451
459
  export class AkanLibConfig implements LibConfigResult {
452
460
  lib: Lib;
453
461
  externalLibs: string[];
454
- constructor(lib: Lib, config: DeepPartial<LibConfigResult>) {
462
+ /** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
463
+ plugins: AkanPlugin[];
464
+ constructor(lib: Lib, config: DeepPartial<LibConfigResult>, plugins: AkanPlugin[] = []) {
455
465
  this.lib = lib;
456
466
  this.externalLibs = config?.externalLibs ?? [];
467
+ this.plugins = plugins;
457
468
  }
458
469
  static async from(lib: Lib) {
459
470
  const [configImp] = await Promise.all([import(`${lib.cwdPath}/akan.config.ts`).then((mod) => mod.default)]);
460
- const config = typeof configImp === "function" ? configImp(lib) : configImp;
461
- return new AkanLibConfig(lib, config);
471
+ const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
472
+ const { plugins, ...config } = (resolved ?? {}) as DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
473
+ return new AkanLibConfig(lib, config, plugins ?? []);
462
474
  }
463
475
  }
464
476
 
@@ -1,9 +1,16 @@
1
1
  export type {
2
2
  AkanConfigFile,
3
+ AkanExecutor,
3
4
  AkanMobileConfig,
4
5
  AkanMobileTargetConfig,
6
+ AkanNativeContext,
7
+ AkanPlugin,
8
+ AkanPluginCapacitorConfig,
5
9
  AkanRouteConfig,
10
+ AkanScanInfo,
11
+ AkanSyncContext,
6
12
  AppConfig,
13
+ AppConfigInput,
7
14
  AppConfigResult,
8
15
  AppScanResult,
9
16
  Arch,
@@ -12,11 +19,13 @@ export type {
12
19
  DockerConfig,
13
20
  FileConventionScanResult,
14
21
  LibConfig,
22
+ LibConfigInput,
15
23
  LibConfigResult,
16
24
  LibScanResult,
17
25
  MobileEnv,
18
26
  MobilePermission,
19
27
  PkgScanResult,
28
+ PluginRuntimeContext,
20
29
  ScanResult,
21
30
  WorkspaceScanResult,
22
31
  } from "akanjs";
package/capacitorApp.ts CHANGED
@@ -6,8 +6,9 @@ import { select } from "@inquirer/prompts";
6
6
  import { MobileProject } from "@trapezedev/project";
7
7
  import type { AndroidProject } from "@trapezedev/project/dist/android/project";
8
8
  import type { IosProject } from "@trapezedev/project/dist/ios/project";
9
+ import type { AkanNativeContext, AkanPlugin } from "akanjs";
9
10
  import { capitalize } from "akanjs/common";
10
- import type { AkanMobileTargetConfig } from "./akanConfig";
11
+ import type { AkanMobileTargetConfig, MobilePermission } from "./akanConfig";
11
12
  import { type AppExecutor, CommandExecutionError } from "./executors";
12
13
  import { FileEditor } from "./fileEditor";
13
14
  import { resolveMobilePath, targetHtmlFilename } from "./mobile";
@@ -26,7 +27,6 @@ interface RunIosConfig extends RunConfig {
26
27
  interface PrepareConfig extends RunConfig {}
27
28
 
28
29
  type MobileCommandEnv = Record<string, string | undefined>;
29
- type IosApnsEnvironment = "development" | "production";
30
30
 
31
31
  export type IosRunTargetKind = "device" | "simulator";
32
32
  export interface IosRunTarget {
@@ -131,9 +131,6 @@ const asString = (value: unknown) => (typeof value === "string" ? value : undefi
131
131
 
132
132
  const firstString = (...values: unknown[]) => values.find((value): value is string => typeof value === "string");
133
133
 
134
- const resolveIosApnsEnvironment = ({ operation }: Pick<RunConfig, "operation" | "env">): IosApnsEnvironment =>
135
- operation === "release" ? "production" : "development";
136
-
137
134
  const scoreIosDeviceTarget = (target: IosRunTarget) => {
138
135
  const state = target.state?.toLowerCase() ?? "";
139
136
  return (state.includes("available") ? 4 : 0) + (state.includes("wired") ? 2 : 0) + (state.includes("paired") ? 1 : 0);
@@ -557,6 +554,9 @@ export class CapacitorApp {
557
554
  readonly iosProjectPath = "ios/App";
558
555
  readonly androidRootPath = "android";
559
556
  readonly androidAssetsPath = "android/app/src/main/assets";
557
+ //* Accumulates iOS entitlements contributed by deep links and plugins during a prepare,
558
+ //* then flushed to App/App.entitlements once (see #flushIosEntitlements).
559
+ #iosEntitlements: Record<string, string | string[]> = {};
560
560
  constructor(
561
561
  private readonly app: AppExecutor,
562
562
  readonly target: AkanMobileTargetConfig,
@@ -605,6 +605,7 @@ export class CapacitorApp {
605
605
  await this.#applyIosMetadata();
606
606
  await this.#applyPermissions({ operation, env });
607
607
  await this.#applyDeepLinks("ios", { operation, env });
608
+ await this.#flushIosEntitlements();
608
609
  await this.project.commit();
609
610
  await this.#generateAssets({ operation, env });
610
611
  this.app.verbose(`syncing iOS`);
@@ -992,13 +993,52 @@ export class CapacitorApp {
992
993
  await this.project.android.setAppName(this.target.appName);
993
994
  }
994
995
  async #applyPermissions({ operation, env }: Pick<RunConfig, "operation" | "env">) {
996
+ const plugins = await this.app.collectPlugins();
997
+ const nativePlugins = new Map<MobilePermission, AkanPlugin[]>();
998
+ for (const plugin of plugins) {
999
+ const permission = plugin.capacitor?.permission;
1000
+ if (!permission || !plugin.capacitor?.configureNative) continue;
1001
+ const claimants = nativePlugins.get(permission) ?? [];
1002
+ claimants.push(plugin);
1003
+ nativePlugins.set(permission, claimants);
1004
+ }
995
1005
  for (const permission of this.target.permissions ?? []) {
996
- if (permission === "camera") await this.addCamera();
997
- else if (permission === "contacts") await this.addContact();
998
- else if (permission === "location") await this.addLocation();
999
- else if (permission === "push") await this.addPush({ operation, env });
1006
+ const claimants = nativePlugins.get(permission);
1007
+ if (!claimants?.length) {
1008
+ this.app.logger.warn(
1009
+ `Mobile target '${this.target.name}' declares permission '${permission}' but no plugin configures it. ` +
1010
+ `Depend on a lib (e.g. @libs/util) whose akan.config declares a plugin with capacitor.permission '${permission}'.`,
1011
+ );
1012
+ continue;
1013
+ }
1014
+ const ctx = this.#makeNativeContext({ operation, env });
1015
+ for (const plugin of claimants) await plugin.capacitor?.configureNative?.(ctx);
1000
1016
  }
1001
1017
  }
1018
+ #makeNativeContext({ operation, env }: Pick<RunConfig, "operation" | "env">): AkanNativeContext {
1019
+ return {
1020
+ appPath: this.app.cwdPath,
1021
+ executor: this.app,
1022
+ target: this.target,
1023
+ operation,
1024
+ env,
1025
+ setIosUsageDescriptions: (descriptions) => this.#setPermissionInIos(descriptions),
1026
+ updateIosInfoPlist: async (values) => {
1027
+ await Promise.all([
1028
+ this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", values),
1029
+ this.project.ios.updateInfoPlist(this.iosTargetName, "Release", values),
1030
+ ]);
1031
+ },
1032
+ addIosEntitlements: (entitlements) => this.#addIosEntitlements(entitlements),
1033
+ editIosAppDelegate: (transform) => this.#editIosAppDelegate(transform),
1034
+ addAndroidPermissions: (permissions) => {
1035
+ this.#setPermissionsInAndroid(permissions);
1036
+ },
1037
+ addAndroidFeatures: (features) => {
1038
+ this.#setFeaturesInAndroid(features);
1039
+ },
1040
+ };
1041
+ }
1002
1042
  async #applyDeepLinks(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
1003
1043
  const deepLinks = this.target.deepLinks;
1004
1044
  if (!deepLinks) return;
@@ -1012,7 +1052,10 @@ export class CapacitorApp {
1012
1052
  });
1013
1053
  await this.#setUrlSchemesInIos(schemes);
1014
1054
  }
1015
- if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains, { operation, env });
1055
+ if (domains.length > 0)
1056
+ this.#addIosEntitlements({
1057
+ "com.apple.developer.associated-domains": domains.map((domain) => `applinks:${domain}`),
1058
+ });
1016
1059
  return;
1017
1060
  }
1018
1061
  if (platform === "android") {
@@ -1036,7 +1079,10 @@ export class CapacitorApp {
1036
1079
  await this.#setDeepLinksInAndroid(schemes, domains);
1037
1080
  }
1038
1081
  }
1039
- #assertDeepLinkVerificationConfig(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
1082
+ #assertDeepLinkVerificationConfig(
1083
+ platform: "ios" | "android",
1084
+ { operation, env }: Pick<RunConfig, "operation" | "env">,
1085
+ ) {
1040
1086
  const deepLinks = this.target.deepLinks;
1041
1087
  if (!deepLinks?.domains?.length) return;
1042
1088
  if (platform === "ios" && !deepLinks.ios?.teamId) {
@@ -1097,79 +1143,18 @@ export class CapacitorApp {
1097
1143
  await this.#clearRootCapacitorConfigs();
1098
1144
  }
1099
1145
  }
1100
- async addCamera() {
1101
- await this.#setPermissionInIos({
1102
- cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
1103
- photoAddUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
1104
- photoUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
1105
- });
1106
- this.#setPermissionsInAndroid(["READ_MEDIA_IMAGES", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"]);
1107
- }
1108
- async addContact() {
1109
- await this.#setPermissionInIos({
1110
- contactsUsageDescription: "$(PRODUCT_NAME) requires access to the contacts to add new contacts.",
1111
- });
1112
- this.#setPermissionsInAndroid(["READ_CONTACTS", "WRITE_CONTACTS"]);
1113
- }
1114
- async addLocation() {
1115
- await this.#setPermissionInIos({
1116
- locationAlwaysUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
1117
- locationWhenInUseUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
1118
- });
1119
- this.#setPermissionsInAndroid(["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"]);
1120
- this.#setFeaturesInAndroid(["android.hardware.location.gps"]);
1146
+ #addIosEntitlements(entitlements: Record<string, string | string[]>) {
1147
+ Object.assign(this.#iosEntitlements, entitlements);
1121
1148
  }
1122
- async addPush({ operation, env }: Pick<RunConfig, "operation" | "env">) {
1123
- await this.#setPermissionInIos({
1124
- userNotificationsUsageDescription: "$(PRODUCT_NAME) uses notifications to keep you updated.",
1125
- });
1126
- await Promise.all([
1127
- this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", { UIBackgroundModes: ["remote-notification"] }),
1128
- this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { UIBackgroundModes: ["remote-notification"] }),
1129
- this.#writeEntitlementsInIos(this.target.deepLinks?.domains ?? [], { operation, env }),
1130
- this.#ensurePushAppDelegateInIos(),
1131
- ]);
1132
- this.#setPermissionsInAndroid(["POST_NOTIFICATIONS"]);
1133
- }
1134
- async #ensurePushAppDelegateInIos() {
1149
+ //* Generic in-place transform of ios/App/App/AppDelegate.swift (no-op if the file is absent).
1150
+ //* Feature-specific native wiring (e.g. Firebase) lives in plugins, not the framework.
1151
+ async #editIosAppDelegate(transform: (content: string) => string) {
1135
1152
  const appDelegatePath = path.join(this.app.cwdPath, this.iosProjectPath, "App/AppDelegate.swift");
1136
1153
  if (!(await Bun.file(appDelegatePath).exists())) return;
1137
-
1138
1154
  const editor = await FileEditor.create(appDelegatePath);
1139
- let content = editor.getContent();
1140
-
1141
- if (!content.includes("import FirebaseCore")) {
1142
- content = content.replace("import Capacitor", "import Capacitor\nimport FirebaseCore");
1143
- }
1144
- if (!content.includes("import FirebaseMessaging")) {
1145
- content = content.replace("import FirebaseCore", "import FirebaseCore\nimport FirebaseMessaging");
1146
- }
1147
- if (!content.includes("FirebaseApp.configure()")) {
1148
- content = content.replace(/\n(\s*)return true/, "\n$1FirebaseApp.configure()\n\n$1return true");
1149
- }
1150
- if (!content.includes("didRegisterForRemoteNotificationsWithDeviceToken")) {
1151
- const delegateMethods = `
1152
- func application(_ application: UIApplication,
1153
- didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
1154
- Messaging.messaging().apnsToken = deviceToken
1155
- NotificationCenter.default.post(
1156
- name: .capacitorDidRegisterForRemoteNotifications,
1157
- object: deviceToken
1158
- )
1159
- }
1160
-
1161
- func application(_ application: UIApplication,
1162
- didFailToRegisterForRemoteNotificationsWithError error: Error) {
1163
- NotificationCenter.default.post(
1164
- name: .capacitorDidFailToRegisterForRemoteNotifications,
1165
- object: error
1166
- )
1167
- }
1168
- `;
1169
- content = content.replace("\n func applicationWillResignActive", `${delegateMethods}\n func applicationWillResignActive`);
1170
- }
1171
-
1172
- if (content !== editor.getContent()) await editor.setContent(content).save();
1155
+ const content = editor.getContent();
1156
+ const next = transform(content);
1157
+ if (next !== content) await editor.setContent(next).save();
1173
1158
  }
1174
1159
  async #setPermissionInIos(permissions: { [key: string]: string }) {
1175
1160
  const updateNs = Object.fromEntries(
@@ -1190,29 +1175,30 @@ export class CapacitorApp {
1190
1175
  this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { CFBundleURLTypes: urlTypes }),
1191
1176
  ]);
1192
1177
  }
1193
- async #setAssociatedDomainsInIos(domains: string[], { operation, env }: Pick<RunConfig, "operation" | "env">) {
1194
- await this.#writeEntitlementsInIos(domains, { operation, env });
1178
+ #serializeIosEntitlements(entitlements: Record<string, string | string[]>) {
1179
+ const lines: string[] = [];
1180
+ for (const [key, value] of Object.entries(entitlements)) {
1181
+ lines.push(` <key>${key}</key>`);
1182
+ if (Array.isArray(value)) {
1183
+ lines.push(" <array>", ...value.map((item) => ` <string>${item}</string>`), " </array>");
1184
+ } else {
1185
+ lines.push(` <string>${value}</string>`);
1186
+ }
1187
+ }
1188
+ return lines;
1195
1189
  }
1196
- async #writeEntitlementsInIos(domains: string[], runConfig: Pick<RunConfig, "operation" | "env">) {
1190
+ //* Write the accumulated iOS entitlements (from deep links + plugins) to App/App.entitlements
1191
+ //* once per prepare. Skipped entirely when nothing contributed entitlements.
1192
+ async #flushIosEntitlements() {
1193
+ if (Object.keys(this.#iosEntitlements).length === 0) return;
1197
1194
  const entitlementsRelPath = "App/App.entitlements";
1198
1195
  const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
1199
- const values = domains.map((domain) => `applinks:${domain}`);
1200
- const usesPush = this.target.permissions?.includes("push") ?? false;
1201
- const apsEnvironment = resolveIosApnsEnvironment(runConfig);
1202
1196
  const body = [
1203
1197
  '<?xml version="1.0" encoding="UTF-8"?>',
1204
1198
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1205
1199
  '<plist version="1.0">',
1206
1200
  "<dict>",
1207
- ...(usesPush ? [" <key>aps-environment</key>", ` <string>${apsEnvironment}</string>`] : []),
1208
- ...(values.length > 0
1209
- ? [
1210
- " <key>com.apple.developer.associated-domains</key>",
1211
- " <array>",
1212
- ...values.map((value) => ` <string>${value}</string>`),
1213
- " </array>",
1214
- ]
1215
- : []),
1201
+ ...this.#serializeIosEntitlements(this.#iosEntitlements),
1216
1202
  "</dict>",
1217
1203
  "</plist>",
1218
1204
  "",
package/executors.ts CHANGED
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import { copyFile, mkdir, readdir as readDirEntries, stat } from "node:fs/promises";
12
12
  import path from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
+ import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
14
15
  import {
15
16
  capitalize,
16
17
  isRouteSourceFile,
@@ -24,11 +25,6 @@ import chalk from "chalk";
24
25
  import ts from "typescript";
25
26
  import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
26
27
  import { FileSys } from "./fileSys";
27
- import {
28
- createFirebaseMessagingServiceWorker,
29
- type FirebaseClientEnvConfig,
30
- normalizeFirebaseClientConfig,
31
- } from "./firebaseMessagingSw";
32
28
  import { getDirname } from "./getDirname";
33
29
  import { Linter } from "./linter";
34
30
  import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
@@ -1087,7 +1083,7 @@ interface SysExecutorOptions {
1087
1083
  type: "app" | "lib";
1088
1084
  }
1089
1085
 
1090
- const scanFacetDirs = ["ui", "webkit", "srvkit", "common"] as const;
1086
+ const scanFacetDirs = ["ui", "webkit", "srvkit", "common", "plugin"] as const;
1091
1087
 
1092
1088
  export class SysExecutor extends Executor {
1093
1089
  workspace: WorkspaceExecutor;
@@ -1548,27 +1544,71 @@ export class AppExecutor extends SysExecutor {
1548
1544
  writeLib: write,
1549
1545
  })) as AppInfo;
1550
1546
  if (write) await this.syncAssets(scanInfo.getScanResult().libDeps);
1551
- if (write) await this.#syncFirebaseMessagingSw();
1547
+ if (write) await this.#runPluginSyncAssets();
1552
1548
  return scanInfo;
1553
1549
  }
1554
- //* firebase config 있으면 public/firebase-messaging-sw.js 1회 생성한다(있으면 스킵).
1555
- //* 서버 동적 라우트를 대체하는 정적 파일. env.client 파생이라 gitignore 되고 env 별로 재생성된다.
1556
- async #syncFirebaseMessagingSw() {
1557
- const swRelPath = "public/firebase-messaging-sw.js";
1558
- if (await FileSys.fileExists(this.getPath(swRelPath))) return;
1559
- const envClientPath = path.join(this.cwdPath, "env", "env.client.ts");
1560
- if (!(await FileSys.fileExists(envClientPath))) return;
1561
- let firebaseConfig: FirebaseClientEnvConfig | null = null;
1562
- try {
1563
- const envUrl = pathToFileURL(envClientPath);
1564
- envUrl.searchParams.set("t", String(Date.now()));
1565
- const envModule = (await import(envUrl.href)) as { env?: { firebase?: unknown } };
1566
- firebaseConfig = normalizeFirebaseClientConfig(envModule.env?.firebase);
1567
- } catch {
1568
- return;
1550
+ //* build-time asset generation is delegated to plugins (e.g. the push plugin writes
1551
+ //* public/firebase-messaging-sw.js). The framework itself no longer knows about firebase.
1552
+ async #runPluginSyncAssets() {
1553
+ const plugins = await this.collectPlugins();
1554
+ if (!plugins.some((plugin) => plugin.syncAssets)) return;
1555
+ const ctx = this.#makeSyncContext();
1556
+ for (const plugin of plugins) await plugin.syncAssets?.(ctx);
1557
+ }
1558
+ #makeSyncContext(): AkanSyncContext {
1559
+ return {
1560
+ appName: this.name,
1561
+ appPath: this.cwdPath,
1562
+ executor: this,
1563
+ getPath: (rel) => this.getPath(rel),
1564
+ fileExists: (rel) => FileSys.fileExists(this.getPath(rel)),
1565
+ writeFile: async (rel, content, opts) => {
1566
+ await this.writeFile(rel, content, opts);
1567
+ },
1568
+ readEnvClient: async () => {
1569
+ const envClientPath = path.join(this.cwdPath, "env", "env.client.ts");
1570
+ if (!(await FileSys.fileExists(envClientPath))) return null;
1571
+ try {
1572
+ const envUrl = pathToFileURL(envClientPath);
1573
+ envUrl.searchParams.set("t", String(Date.now()));
1574
+ const envModule = (await import(envUrl.href)) as { env?: Record<string, unknown> };
1575
+ return envModule.env ?? null;
1576
+ } catch {
1577
+ return null;
1578
+ }
1579
+ },
1580
+ };
1581
+ }
1582
+ //* Aggregate plugins declared by this app's akan.config plus each of its lib dependencies'
1583
+ //* akan.config. This is how a lib (e.g. libs/util) opts an app into a feature turnkey.
1584
+ async collectPlugins(): Promise<AkanPlugin[]> {
1585
+ const scanInfo = (await this.scan({ write: false })) as AppInfo;
1586
+ const libDeps = scanInfo.getLibs();
1587
+ const appConfig = await this.getConfig();
1588
+ const collected: AkanPlugin[] = [...appConfig.plugins];
1589
+ for (const libName of libDeps) {
1590
+ const libConfig = await LibExecutor.from(this, libName)
1591
+ .getConfig()
1592
+ .catch(() => null);
1593
+ if (libConfig) collected.push(...libConfig.plugins);
1569
1594
  }
1570
- if (!firebaseConfig) return;
1571
- await this.writeFile(swRelPath, createFirebaseMessagingServiceWorker(firebaseConfig), { overwrite: false });
1595
+ const seen = new Set<string>();
1596
+ return collected.filter((plugin) => {
1597
+ if (seen.has(plugin.name)) return false;
1598
+ seen.add(plugin.name);
1599
+ return true;
1600
+ });
1601
+ }
1602
+ async getPluginRuntimePackages(): Promise<string[]> {
1603
+ const plugins = await this.collectPlugins();
1604
+ const appConfig = await this.getConfig();
1605
+ const ctx: PluginRuntimeContext = {
1606
+ appName: this.name,
1607
+ mobile: appConfig.mobile,
1608
+ hasMobilePermission: (permission) =>
1609
+ Object.values(appConfig.mobile.targets).some((target) => target.permissions?.includes(permission) ?? false),
1610
+ };
1611
+ return [...new Set(plugins.flatMap((plugin) => plugin.runtimePackages?.(ctx) ?? []))];
1572
1612
  }
1573
1613
  async increaseBuildNum() {
1574
1614
  await increaseBuildNum(this);
@@ -3,7 +3,7 @@ import type { ChangeKind, DevChangeAction, DevChangePlan, DevChangeRole } from "
3
3
 
4
4
  const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
5
5
  const CONFIG_BASENAMES = new Set(["akan.config.ts", "bunfig.toml", "tsconfig.json", "package.json"]);
6
- const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
6
+ const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit", "plugin"]);
7
7
  const CLIENT_SUFFIXES = [".Template.tsx", ".Unit.tsx", ".Util.tsx", ".View.tsx", ".Zone.tsx", ".store.ts"];
8
8
  const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
9
9
  const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
4
+ const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit", "plugin"]);
5
5
  const FACET_SOURCE_FILE_RE = /\.(ts|tsx)$/;
6
6
  const FACET_EXCLUDED_FILE_RE = /(^index\.tsx?$|\.d\.ts$|\.(test|spec)\.(ts|tsx)$|\.css$|\.scss$|\.sass$)/;
7
7
  // `ui` exports PascalCase names only; `common`/`srvkit`/`webkit` export camelCase names only. Names with
package/index.ts CHANGED
@@ -18,7 +18,6 @@ export * from "./dependencyScanner";
18
18
  export * from "./executors";
19
19
  export * from "./extractDeps";
20
20
  export * from "./fileSys";
21
- export * from "./firebaseMessagingSw";
22
21
  export * from "./frontendBuild";
23
22
  export * from "./getCredentials";
24
23
  export * from "./getDirname";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.12",
3
+ "version": "2.3.13-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.12",
35
+ "akanjs": "2.3.13-rc.1",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
package/qualityScanner.ts CHANGED
@@ -74,7 +74,7 @@ const SUGGESTED_RULES = [
74
74
  "Dictionary text should not contain scaffold wording, misspellings, or stale copied domain nouns.",
75
75
  "Warn earlier on very long Akan files: 500 lines for services, 800 lines for Template/Zone files, and 1000 lines for Util files.",
76
76
  "Global declarations, Window augmentation, and prototype mutation should stay in explicitly approved low-level integration files.",
77
- "Keep app root folders small and conventional: application code belongs under common, env, lib, page, private, public, script, srvkit, ui, or webkit.",
77
+ "Keep app root folders small and conventional: application code belongs under common, env, lib, page, plugin, private, public, script, srvkit, ui, or webkit.",
78
78
  "Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
79
79
  "Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
80
80
  "Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
@@ -1,66 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- createFirebaseMessagingServiceWorker,
4
- normalizeFirebaseClientConfig,
5
- } from "./firebaseMessagingSw";
6
-
7
- describe("normalizeFirebaseClientConfig", () => {
8
- test("whitelists client fields and drops secrets / vapidKey", () => {
9
- const normalized = normalizeFirebaseClientConfig({
10
- apiKey: "public-api-key",
11
- authDomain: "example.firebaseapp.com",
12
- projectId: "public-project",
13
- storageBucket: "public-project.appspot.com",
14
- messagingSenderId: "1234567890",
15
- appId: "public-app-id",
16
- vapidKey: "public-vapid-key",
17
- private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
18
- });
19
- expect(normalized).toEqual({
20
- apiKey: "public-api-key",
21
- authDomain: "example.firebaseapp.com",
22
- projectId: "public-project",
23
- storageBucket: "public-project.appspot.com",
24
- messagingSenderId: "1234567890",
25
- appId: "public-app-id",
26
- });
27
- expect(normalized).not.toHaveProperty("vapidKey");
28
- expect(normalized).not.toHaveProperty("private_key");
29
- });
30
-
31
- test("returns null when required fields are missing or input is not an object", () => {
32
- expect(normalizeFirebaseClientConfig(undefined)).toBe(null);
33
- expect(normalizeFirebaseClientConfig(null)).toBe(null);
34
- expect(normalizeFirebaseClientConfig("nope")).toBe(null);
35
- expect(normalizeFirebaseClientConfig({ apiKey: "only-key" })).toBe(null);
36
- });
37
- });
38
-
39
- describe("createFirebaseMessagingServiceWorker", () => {
40
- test("inlines client config and imports firebase compat SDK, without leaking secrets", () => {
41
- const config = normalizeFirebaseClientConfig({
42
- apiKey: "public-api-key",
43
- projectId: "public-project",
44
- messagingSenderId: "1234567890",
45
- appId: "public-app-id",
46
- private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
47
- });
48
- const body = createFirebaseMessagingServiceWorker(config);
49
- expect(body).toContain("public-api-key");
50
- expect(body).toContain("public-project");
51
- expect(body).toContain("firebase-messaging-compat.js");
52
- expect(body).toContain("onBackgroundMessage");
53
- expect(body).toContain("notificationclick");
54
- expect(body).not.toContain("SERVER_PRIVATE_KEY_MUST_NOT_LEAK");
55
- expect(body).not.toContain("private_key");
56
- });
57
-
58
- test("produces a no-op worker (config null) that still registers notificationclick", () => {
59
- const body = createFirebaseMessagingServiceWorker(null);
60
- // config is null → the importScripts/onBackgroundMessage block is gated by `if (firebaseConfig)` at runtime,
61
- // but notificationclick is always registered.
62
- expect(body).toContain("const firebaseConfig = null");
63
- expect(body).toContain("notificationclick");
64
- expect(body).toContain("if (firebaseConfig)");
65
- });
66
- });
@@ -1,74 +0,0 @@
1
- const FIREBASE_WEB_SDK_VERSION = "12.13.0";
2
-
3
- export interface FirebaseClientEnvConfig {
4
- apiKey: string;
5
- authDomain?: string;
6
- projectId: string;
7
- storageBucket?: string;
8
- messagingSenderId: string;
9
- appId: string;
10
- vapidKey?: string;
11
- }
12
-
13
- //* env.client 의 firebase 설정을 검증하고, 서비스워커에 필요한 필드만 화이트리스트한다.
14
- //* vapidKey/서버 시크릿 등 나머지 필드는 의도적으로 제외한다(SW 본문에 유출 방지).
15
- export function normalizeFirebaseClientConfig(config: unknown): FirebaseClientEnvConfig | null {
16
- if (!config || typeof config !== "object") return null;
17
- const value = config as Partial<Record<keyof FirebaseClientEnvConfig, unknown>>;
18
- if (
19
- typeof value.apiKey !== "string" ||
20
- typeof value.projectId !== "string" ||
21
- typeof value.messagingSenderId !== "string" ||
22
- typeof value.appId !== "string"
23
- ) {
24
- return null;
25
- }
26
- return {
27
- apiKey: value.apiKey,
28
- ...(typeof value.authDomain === "string" ? { authDomain: value.authDomain } : {}),
29
- projectId: value.projectId,
30
- ...(typeof value.storageBucket === "string" ? { storageBucket: value.storageBucket } : {}),
31
- messagingSenderId: value.messagingSenderId,
32
- appId: value.appId,
33
- };
34
- }
35
-
36
- //* firebase-messaging-sw.js 생성 코드
37
- export function createFirebaseMessagingServiceWorker(config: FirebaseClientEnvConfig | null): string {
38
- const configJson = JSON.stringify(config);
39
- return `/* Generated by Akan.js. Do not edit. */
40
- const firebaseConfig = ${configJson};
41
-
42
- if (firebaseConfig) {
43
- importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-app-compat.js");
44
- importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-messaging-compat.js");
45
-
46
- firebase.initializeApp(firebaseConfig);
47
- const messaging = firebase.messaging();
48
-
49
- const notificationUrl = (payload) =>
50
- payload?.data?.url || payload?.fcmOptions?.link || payload?.notification?.click_action;
51
-
52
- messaging.onBackgroundMessage((payload) => {
53
- const title = payload?.notification?.title || "";
54
- const options = {
55
- body: payload?.notification?.body,
56
- icon: payload?.notification?.icon,
57
- image: payload?.notification?.image,
58
- data: {
59
- url: notificationUrl(payload),
60
- FCM_MSG: payload,
61
- },
62
- };
63
- self.registration.showNotification(title, options);
64
- });
65
- }
66
-
67
- self.addEventListener("notificationclick", (event) => {
68
- const url = event.notification?.data?.url || event.notification?.data?.FCM_MSG?.data?.url;
69
- event.notification?.close();
70
- if (!url) return;
71
- event.waitUntil(clients.openWindow(url));
72
- });
73
- `;
74
- }