akanjs 2.0.5 → 2.0.7

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.
Files changed (81) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/cli/application/application.command.ts +4 -1
  4. package/cli/application/application.runner.ts +6 -8
  5. package/cli/build.ts +3 -1
  6. package/cli/cloud/cloud.runner.ts +7 -8
  7. package/cli/index.js +288 -115
  8. package/cli/library/library.runner.ts +2 -2
  9. package/cli/module/module.runner.ts +2 -2
  10. package/cli/npmRegistry.ts +13 -0
  11. package/cli/openBrowser.ts +15 -0
  12. package/cli/pluralizeName.ts +5 -0
  13. package/cli/scalar/scalar.prompt.ts +2 -2
  14. package/cli/scalar/scalar.runner.ts +2 -2
  15. package/cli/semver.ts +18 -0
  16. package/cli/templates/lib/sig.ts +2 -2
  17. package/cli/workspace/workspace.runner.ts +3 -3
  18. package/client/cookie.ts +10 -15
  19. package/common/index.ts +1 -0
  20. package/common/jwtDecode.ts +17 -0
  21. package/constant/serialize.ts +1 -1
  22. package/devkit/akanApp/akanApp.host.ts +46 -9
  23. package/devkit/akanConfig/akanConfig.ts +2 -1
  24. package/devkit/capacitor.base.config.ts +18 -4
  25. package/devkit/capacitorApp.ts +118 -64
  26. package/devkit/incrementalBuilder/incrementalBuilder.host.ts +83 -9
  27. package/devkit/mobile/mobileTarget.ts +2 -1
  28. package/devkit/scanInfo.ts +1 -0
  29. package/document/dataLoader.ts +140 -6
  30. package/document/database.ts +1 -1
  31. package/package.json +7 -13
  32. package/server/akanApp.ts +250 -44
  33. package/server/di/diLifecycle.ts +1 -1
  34. package/server/processMetricsCollector.ts +79 -1
  35. package/server/proxy/localeWebProxy.ts +29 -12
  36. package/server/resolver/database.resolver.ts +82 -31
  37. package/server/resolver/signal.resolver.ts +67 -28
  38. package/service/ipcTypes.ts +5 -0
  39. package/service/predefinedAdaptor/database.adaptor.ts +95 -27
  40. package/service/predefinedAdaptor/solidSqlite.ts +7 -7
  41. package/service/predefinedAdaptor/storage.adaptor.ts +35 -9
  42. package/service/serviceModule.ts +1 -6
  43. package/signal/base.signal.ts +1 -1
  44. package/signal/index.ts +1 -0
  45. package/signal/middleware.ts +5 -1
  46. package/signal/signalContext.ts +85 -31
  47. package/signal/signalRegistry.ts +35 -10
  48. package/signal/trace.ts +279 -0
  49. package/types/cli/npmRegistry.d.ts +1 -0
  50. package/types/cli/openBrowser.d.ts +1 -0
  51. package/types/cli/pluralizeName.d.ts +1 -0
  52. package/types/cli/semver.d.ts +1 -0
  53. package/types/client/cookie.d.ts +6 -1
  54. package/types/common/index.d.ts +1 -0
  55. package/types/common/jwtDecode.d.ts +2 -0
  56. package/types/devkit/capacitorApp.d.ts +14 -5
  57. package/types/devkit/incrementalBuilder/incrementalBuilder.host.d.ts +9 -5
  58. package/types/document/dataLoader.d.ts +21 -2
  59. package/types/document/database.d.ts +1 -1
  60. package/types/server/processMetricsCollector.d.ts +2 -0
  61. package/types/service/ipcTypes.d.ts +5 -0
  62. package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -32
  63. package/types/service/predefinedAdaptor/solidSqlite.d.ts +3 -3
  64. package/types/service/predefinedAdaptor/storage.adaptor.d.ts +8 -2
  65. package/types/service/serviceModule.d.ts +1 -1
  66. package/types/signal/index.d.ts +1 -0
  67. package/types/signal/signalContext.d.ts +4 -1
  68. package/types/signal/signalRegistry.d.ts +25 -4
  69. package/types/signal/trace.d.ts +97 -0
  70. package/types/ui/Signal/style.d.ts +15 -0
  71. package/ui/Signal/Arg.tsx +22 -15
  72. package/ui/Signal/Doc.tsx +30 -24
  73. package/ui/Signal/Listener.tsx +15 -39
  74. package/ui/Signal/Message.tsx +32 -50
  75. package/ui/Signal/Object.tsx +16 -13
  76. package/ui/Signal/PubSub.tsx +29 -47
  77. package/ui/Signal/Response.tsx +7 -17
  78. package/ui/Signal/RestApi.tsx +41 -57
  79. package/ui/Signal/WebSocket.tsx +1 -1
  80. package/ui/Signal/style.ts +36 -0
  81. package/webkit/useCsrValues.ts +147 -37
@@ -15,37 +15,53 @@ interface RunConfig {
15
15
  regenerate?: boolean;
16
16
  }
17
17
 
18
+ interface PrepareConfig extends RunConfig {}
19
+
18
20
  export class CapacitorApp {
19
21
  project: MobileProject & { ios: IosProject; android: AndroidProject };
20
22
  iosTargetName = "App";
21
23
  readonly targetRoot: string;
24
+ readonly targetRootPath: string;
25
+ readonly targetWebRoot: string;
26
+ readonly targetAssetRoot: string;
27
+ readonly iosRootPath = "ios";
28
+ readonly iosProjectPath = "ios/App";
29
+ readonly androidRootPath = "android";
22
30
  constructor(
23
31
  private readonly app: AppExecutor,
24
32
  readonly target: AkanMobileTargetConfig,
25
33
  ) {
26
- this.targetRoot = path.join(this.app.cwdPath, "mobile", this.target.name);
27
- this.project = new MobileProject(this.targetRoot, {
28
- android: { path: "android" },
29
- ios: { path: "ios/App" },
34
+ this.targetRootPath = path.posix.join("mobile", this.target.name);
35
+ this.targetRoot = path.join(this.app.cwdPath, this.targetRootPath);
36
+ this.targetWebRoot = path.join(this.targetRoot, "www");
37
+ this.targetAssetRoot = path.join(this.targetRoot, "assets");
38
+ this.project = new MobileProject(this.app.cwdPath, {
39
+ android: { path: this.androidRootPath },
40
+ ios: { path: this.iosProjectPath },
30
41
  }) as MobileProject & { ios: IosProject; android: AndroidProject };
31
42
  }
32
- async init({ platform, regenerate = false }: { platform?: "ios" | "android"; regenerate?: boolean } = {}) {
43
+ async init({
44
+ platform,
45
+ operation = "release",
46
+ env = "debug",
47
+ regenerate = false,
48
+ }: { platform?: "ios" | "android" } & Partial<PrepareConfig> = {}) {
33
49
  await mkdir(this.targetRoot, { recursive: true });
34
50
  await this.#writeCapacitorConfig();
35
51
  if (regenerate) {
36
52
  if (!platform || platform === "ios")
37
- await rm(path.join(this.targetRoot, "ios"), { recursive: true, force: true });
53
+ await rm(path.join(this.app.cwdPath, this.iosRootPath), { recursive: true, force: true });
38
54
  if (!platform || platform === "android")
39
- await rm(path.join(this.targetRoot, "android"), { recursive: true, force: true });
55
+ await rm(path.join(this.app.cwdPath, this.androidRootPath), { recursive: true, force: true });
40
56
  }
41
57
  const project = this.project as MobileProject;
42
58
  await this.project.load();
43
59
  if ((!platform || platform === "android") && !project.android) {
44
- await this.#spawn("npx", ["cap", "add", "android"]);
60
+ await this.#spawnMobile("npx", ["cap", "add", "android"], { operation, env });
45
61
  await this.project.load();
46
62
  }
47
63
  if ((!platform || platform === "ios") && !project.ios) {
48
- await this.#spawn("npx", ["cap", "add", "ios"]);
64
+ await this.#spawnMobile("npx", ["cap", "add", "ios"], { operation, env });
49
65
  await this.project.load();
50
66
  }
51
67
  return this;
@@ -53,58 +69,54 @@ export class CapacitorApp {
53
69
  async save() {
54
70
  await this.project.commit();
55
71
  }
56
- async #prepareIos({ regenerate = false }: { regenerate?: boolean } = {}) {
57
- await this.init({ platform: "ios", regenerate });
72
+ async #prepareIos({ operation, env, regenerate = false }: PrepareConfig) {
73
+ await this.init({ platform: "ios", operation, env, regenerate });
58
74
  await this.#prepareTargetAssets();
59
75
  await this.#prepareExternalFiles("ios");
60
76
  await this.#applyIosMetadata();
61
77
  await this.#applyPermissions();
62
78
  await this.#applyLinks();
63
79
  await this.project.commit();
64
- await this.#generateAssets();
80
+ await this.#generateAssets({ operation, env });
65
81
  this.app.verbose(`syncing iOS`);
66
- await this.#spawn("npx", ["cap", "sync", "ios"]);
82
+ await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env });
67
83
  this.app.verbose(`sync completed.`);
68
84
  }
69
- async buildIos(options: { regenerate?: boolean } = {}) {
85
+ async buildIos({ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {}) {
70
86
  await this.prepareWww();
71
- await this.#prepareIos(options);
72
- await this.#spawn("npx", ["cap", "build", "ios"], { stdio: "inherit" });
87
+ await this.#prepareIos({ operation: "release", env, regenerate });
88
+ await this.#spawnMobile("npx", ["cap", "build", "ios"], { operation: "release", env }, { stdio: "inherit" });
73
89
  this.app.verbose(`build completed iOS.`);
74
90
  return;
75
91
  }
76
92
  async syncIos() {
77
- await this.#spawn("npx", ["cap", "sync", "ios"]);
93
+ await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation: "local", env: "local" });
78
94
  }
79
95
  async openIos() {
80
- await this.#spawn("npx", ["cap", "open", "ios"]);
96
+ await this.#spawnMobile("npx", ["cap", "open", "ios"], { operation: "local", env: "local" });
81
97
  }
82
98
  async runIos({ operation, env, regenerate = false }: RunConfig) {
83
99
  if (operation === "release") await this.prepareWww();
84
- await this.#prepareIos({ regenerate });
100
+ await this.#prepareIos({ operation, env, regenerate });
85
101
  const args = ["cap", "run", "ios"];
86
- if (operation !== "release") args.push("--live-reload");
87
- await this.#spawn("npx", args, {
88
- env: this.#commandEnv(operation, env),
89
- stdio: "inherit",
90
- });
102
+ await this.#spawnMobile("npx", args, { operation, env }, { stdio: "inherit" });
91
103
  }
92
104
 
93
- async #prepareAndroid({ regenerate = false }: { regenerate?: boolean } = {}) {
94
- await this.init({ platform: "android", regenerate });
105
+ async #prepareAndroid({ operation, env, regenerate = false }: PrepareConfig) {
106
+ await this.init({ platform: "android", operation, env, regenerate });
95
107
  await this.#prepareTargetAssets();
96
108
  await this.#prepareExternalFiles("android");
97
109
  await this.#applyAndroidMetadata();
98
110
  await this.#applyPermissions();
99
111
  await this.#applyLinks();
100
112
  await this.project.commit();
101
- await this.#generateAssets();
102
- await this.#spawn("npx", ["cap", "sync", "android"]);
113
+ await this.#generateAssets({ operation, env });
114
+ await this.#spawnMobile("npx", ["cap", "sync", "android"], { operation, env });
103
115
  }
104
116
 
105
117
  async #updateAndroidBuildTypes() {
106
118
 
107
- const appGradle = await FileEditor.create(`${this.targetRoot}/android/app/build.gradle`);
119
+ const appGradle = await FileEditor.create(path.join(this.app.cwdPath, this.androidRootPath, "app/build.gradle"));
108
120
  const buildTypesBlock = `
109
121
  debug {
110
122
  applicationIdSuffix ".debug"
@@ -139,9 +151,12 @@ export class CapacitorApp {
139
151
  }
140
152
  await appGradle.save();
141
153
  }
142
- async buildAndroid(assembleType: "apk" | "aab", options: { regenerate?: boolean } = {}) {
154
+ async buildAndroid(
155
+ assembleType: "apk" | "aab",
156
+ { env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {},
157
+ ) {
143
158
  await this.prepareWww();
144
- await this.#prepareAndroid(options);
159
+ await this.#prepareAndroid({ operation: "release", env, regenerate });
145
160
  await this.#updateAndroidBuildTypes();
146
161
 
147
162
  const isWindows = process.platform === "win32";
@@ -149,45 +164,44 @@ export class CapacitorApp {
149
164
 
150
165
  await this.app.spawn(gradleCommand, [assembleType === "apk" ? "assembleRelease" : "bundleRelease"], {
151
166
  stdio: "inherit",
152
- cwd: `${this.targetRoot}/android`,
167
+ cwd: path.join(this.app.cwdPath, this.androidRootPath),
168
+ env: this.#commandEnv("release", env),
153
169
  });
154
170
  }
155
171
  async openAndroid() {
156
- await this.#spawn("npx", ["cap", "open", "android"]);
172
+ await this.#spawnMobile("npx", ["cap", "open", "android"], { operation: "local", env: "local" });
157
173
  }
158
174
  async syncAndroid(options: { regenerate?: boolean } = {}) {
159
175
  await this.prepareWww();
160
- await this.#prepareAndroid(options);
176
+ await this.#prepareAndroid({ operation: "release", env: "debug", ...options });
161
177
  this.app.log(`Sync Android Completed.`);
162
178
  }
163
179
  async runAndroid({ operation, env, regenerate = false }: RunConfig) {
164
180
  if (operation === "release") await this.prepareWww();
165
- await this.#prepareAndroid({ regenerate });
181
+ await this.#prepareAndroid({ operation, env, regenerate });
166
182
  this.app.logger.info(`Running Android in ${operation} mode on ${env} env`);
167
183
  const args = ["cap", "run", "android"];
168
- if (operation !== "release") args.push("--live-reload");
169
- await this.#spawn("npx", args, {
170
- env: this.#commandEnv(operation, env),
171
- stdio: "inherit",
172
- });
184
+ await this.#spawnMobile("npx", args, { operation, env }, { stdio: "inherit" });
173
185
  }
174
186
 
175
187
  async releaseIos() {
176
188
  await this.prepareWww();
177
- await this.#prepareIos();
189
+ await this.#prepareIos({ operation: "release", env: "main" });
178
190
  }
179
191
  async releaseAndroid() {
180
192
  await this.prepareWww();
181
- await this.#prepareAndroid();
193
+ await this.#prepareAndroid({ operation: "release", env: "main" });
182
194
  }
183
195
  async prepareWww() {
184
196
  const htmlSource = path.join(this.app.dist.cwdPath, "csr", targetHtmlFilename(this.target));
185
197
  if (!(await Bun.file(htmlSource).exists()))
186
198
  throw new Error(`CSR html for mobile target '${this.target.name}' not found: ${htmlSource}`);
187
- const wwwDir = path.join(this.targetRoot, "www");
188
- await rm(wwwDir, { recursive: true, force: true });
189
- await mkdir(wwwDir, { recursive: true });
190
- await Bun.write(path.join(wwwDir, "index.html"), this.#injectMobileTargetMeta(await Bun.file(htmlSource).text()));
199
+ await rm(this.targetWebRoot, { recursive: true, force: true });
200
+ await mkdir(this.targetWebRoot, { recursive: true });
201
+ await Bun.write(
202
+ path.join(this.targetWebRoot, "index.html"),
203
+ this.#injectMobileTargetMeta(await Bun.file(htmlSource).text()),
204
+ );
191
205
  }
192
206
  #injectMobileTargetMeta(html: string) {
193
207
  const basePath = this.target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
@@ -198,45 +212,75 @@ export class CapacitorApp {
198
212
  async #writeCapacitorConfig() {
199
213
  await mkdir(this.targetRoot, { recursive: true });
200
214
  const appInfoPath = path
201
- .relative(this.targetRoot, path.join(this.app.cwdPath, "akan.app.json"))
215
+ .relative(this.app.cwdPath, path.join(this.app.cwdPath, "akan.app.json"))
202
216
  .split(path.sep)
203
217
  .join("/");
204
218
  const baseConfigPath = path
205
- .relative(this.targetRoot, path.join(this.app.workspace.cwdPath, "pkgs/akanjs/devkit/capacitor.base.config"))
219
+ .relative(this.app.cwdPath, path.join(this.app.workspace.cwdPath, "pkgs/akanjs/devkit/capacitor.base.config"))
206
220
  .split(path.sep)
207
221
  .join("/");
208
- const content = `import { withBase } from "${baseConfigPath.startsWith(".") ? baseConfigPath : `./${baseConfigPath}`}";
209
- import appInfo from "${appInfoPath}";
222
+ const content = `import type { AppScanResult } from "akanjs/devkit";
223
+ import { withBase } from "${baseConfigPath.startsWith(".") ? baseConfigPath : `./${baseConfigPath}`}";
224
+ import appInfo from "${appInfoPath.startsWith(".") ? appInfoPath : `./${appInfoPath}`}";
210
225
 
211
- export default withBase((config) => config, appInfo, "${this.target.name}");
226
+ export default withBase(
227
+ (config, target) => ({
228
+ ...config,
229
+ webDir: \`mobile/\${target.name}/www\`,
230
+ android: {
231
+ ...config.android,
232
+ path: "android",
233
+ },
234
+ ios: {
235
+ ...config.ios,
236
+ path: "ios",
237
+ },
238
+ }),
239
+ appInfo as AppScanResult,
240
+ );
212
241
  `;
213
- await Bun.write(path.join(this.targetRoot, "capacitor.config.ts"), content);
242
+ await Bun.write(path.join(this.app.cwdPath, "capacitor.config.ts"), content);
214
243
  }
215
244
  async #prepareTargetAssets() {
216
245
  if (!this.target.assets) return;
217
- const assetsDir = path.join(this.targetRoot, "assets");
218
- await mkdir(assetsDir, { recursive: true });
246
+ await mkdir(this.targetAssetRoot, { recursive: true });
219
247
  if (this.target.assets.icon)
220
- await cp(path.join(this.app.cwdPath, this.target.assets.icon), path.join(assetsDir, "icon.png"), { force: true });
248
+ await cp(path.join(this.app.cwdPath, this.target.assets.icon), path.join(this.targetAssetRoot, "icon.png"), {
249
+ force: true,
250
+ });
221
251
  if (this.target.assets.splash)
222
- await cp(path.join(this.app.cwdPath, this.target.assets.splash), path.join(assetsDir, "splash.png"), {
252
+ await cp(path.join(this.app.cwdPath, this.target.assets.splash), path.join(this.targetAssetRoot, "splash.png"), {
223
253
  force: true,
224
254
  });
225
255
  }
226
256
  async #prepareExternalFiles(platform: "ios" | "android") {
227
257
  const files = this.target.files?.[platform];
228
258
  if (!files) return;
259
+ const platformRoot = path.join(this.app.cwdPath, platform === "ios" ? this.iosRootPath : this.androidRootPath);
229
260
  await Promise.all(
230
261
  Object.entries(files).map(async ([to, from]) => {
231
- const targetPath = path.join(this.targetRoot, platform, to);
262
+ const targetPath = path.join(platformRoot, to);
232
263
  await mkdir(path.dirname(targetPath), { recursive: true });
233
264
  await cp(path.join(this.app.cwdPath, from), targetPath, { force: true });
234
265
  }),
235
266
  );
236
267
  }
237
- async #generateAssets() {
268
+ async #generateAssets({ operation, env }: Pick<RunConfig, "operation" | "env">) {
238
269
  if (!this.target.assets) return;
239
- await this.#spawn("npx", ["@capacitor/assets", "generate"]);
270
+ await this.#spawnMobile(
271
+ "npx",
272
+ [
273
+ "@capacitor/assets",
274
+ "generate",
275
+ "--assetPath",
276
+ path.posix.join(this.targetRootPath, "assets"),
277
+ "--iosProject",
278
+ this.iosProjectPath,
279
+ "--androidProject",
280
+ this.androidRootPath,
281
+ ],
282
+ { operation, env },
283
+ );
240
284
  }
241
285
  async #applyIosMetadata() {
242
286
  this.project.ios.setBundleId("App", "Debug", this.target.appId);
@@ -291,16 +335,26 @@ export default withBase((config) => config, appInfo, "${this.target.name}");
291
335
  }
292
336
  }
293
337
  #commandEnv(operation: "local" | "release", env: "local" | "debug" | "develop" | "main") {
294
- return {
295
- ...process.env,
338
+ return this.app.getCommandEnv({
296
339
  APP_OPERATION_MODE: operation,
297
- AKAN_PUBLIC_OPERATION_MODE: operation,
340
+ AKAN_PUBLIC_OPERATION_MODE: env === "local" ? "local" : "cloud",
298
341
  AKAN_PUBLIC_ENV: env,
299
342
  AKAN_MOBILE_TARGET: this.target.name,
300
- };
343
+ });
301
344
  }
302
345
  async #spawn(command: string, args: string[] = [], options: Parameters<AppExecutor["spawn"]>[2] = {}) {
303
- return await this.app.spawn(command, args, { cwd: this.targetRoot, ...options });
346
+ return await this.app.spawn(command, args, { cwd: this.app.cwdPath, ...options });
347
+ }
348
+ async #spawnMobile(
349
+ command: string,
350
+ args: string[] = [],
351
+ { operation, env }: Pick<RunConfig, "operation" | "env">,
352
+ options: Parameters<AppExecutor["spawn"]>[2] = {},
353
+ ) {
354
+ return await this.#spawn(command, args, {
355
+ ...options,
356
+ env: { ...this.#commandEnv(operation, env), ...options.env },
357
+ });
304
358
  }
305
359
  async addCamera() {
306
360
  await this.#setPermissionInIos({
@@ -17,7 +17,17 @@ interface IncrementalBuilderHostOptions {
17
17
  onMessage: (message: BuilderMessage) => void;
18
18
  }
19
19
 
20
+ type IncrementalBuilderStatus = "starting" | "ready" | "restarting" | "stopped";
21
+
22
+ interface IncrementalBuilderStartOptions {
23
+ onExit?: () => void;
24
+ onReady?: () => void;
25
+ onRestartReady?: () => void;
26
+ }
27
+
20
28
  export class IncrementalBuilderHost {
29
+ static readonly #restartBaseDelayMs = 1_000;
30
+ static readonly #restartMaxDelayMs = 30_000;
21
31
  logger = new Logger("IncrementalBuilderHost");
22
32
  entry: string;
23
33
  env: Record<string, string>;
@@ -25,42 +35,106 @@ export class IncrementalBuilderHost {
25
35
  ready = false;
26
36
  readonly #onMessage: (message: BuilderMessage) => void;
27
37
  #proc: Bun.Subprocess<"ignore", "inherit", "inherit"> | null = null;
38
+ #status: IncrementalBuilderStatus = "stopped";
39
+ #restartAttempts = 0;
40
+ #restartTimer: ReturnType<typeof setTimeout> | null = null;
41
+ #manualStop = false;
42
+ #startOptions: IncrementalBuilderStartOptions = {};
28
43
  constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
29
44
  this.app = app;
30
45
  this.entry = entry;
31
46
  this.env = env;
32
47
  this.#onMessage = onMessage;
33
48
  }
34
- start({ onExit, onReady }: { onExit?: () => void; onReady?: () => void }) {
49
+ get status() {
50
+ return this.#status;
51
+ }
52
+ start(options: IncrementalBuilderStartOptions = {}) {
35
53
  if (this.#proc) this.stop();
36
- this.#proc = Bun.spawn(["bun", this.entry], {
54
+ this.#manualStop = false;
55
+ this.#startOptions = options;
56
+ this.#spawn(false);
57
+ return this;
58
+ }
59
+ #spawn(isRestart: boolean) {
60
+ this.#status = isRestart ? "restarting" : "starting";
61
+ this.ready = false;
62
+ let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
63
+ proc = Bun.spawn(["bun", this.entry], {
37
64
  cwd: this.app.cwdPath,
38
65
  env: { ...this.env, AKAN_WATCH: "1" },
39
66
  stdio: ["ignore", "inherit", "inherit"],
40
67
  ipc: (msg: BuilderMessage) => {
68
+ if (this.#proc !== proc) return;
41
69
  if (!msg || typeof msg !== "object") return;
42
70
  if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
43
71
  if (msg.type === "builder-ready" && !this.ready) {
44
72
  this.ready = true;
45
- onReady?.();
73
+ this.#status = "ready";
74
+ this.#restartAttempts = 0;
75
+ if (isRestart) this.#startOptions.onRestartReady?.();
76
+ else this.#startOptions.onReady?.();
46
77
  }
47
78
  },
48
79
  serialization: "advanced",
49
80
  onExit: () => {
50
- onExit?.();
81
+ if (this.#proc !== proc) return;
82
+ this.#proc = null;
83
+ const wasReady = this.ready;
84
+ this.ready = false;
85
+ if (this.#manualStop || this.#status === "stopped") return;
86
+ if (!wasReady) {
87
+ this.#status = "stopped";
88
+ this.#startOptions.onExit?.();
89
+ return;
90
+ }
91
+ this.#scheduleRestart();
51
92
  },
52
93
  });
53
- this.logger.verbose(`builder spawned pid=${this.#proc.pid} entry=${this.entry}`);
54
- return this;
94
+ this.#proc = proc;
95
+ this.logger.verbose(`builder spawned pid=${proc.pid} entry=${this.entry}${isRestart ? " restart=1" : ""}`);
96
+ }
97
+ #scheduleRestart() {
98
+ if (this.#manualStop || this.#restartTimer) return;
99
+ this.#status = "restarting";
100
+ const attempt = this.#restartAttempts;
101
+ const delay = Math.min(
102
+ IncrementalBuilderHost.#restartBaseDelayMs * 2 ** attempt,
103
+ IncrementalBuilderHost.#restartMaxDelayMs,
104
+ );
105
+ this.#restartAttempts = attempt + 1;
106
+ this.logger.warn(`builder exited after ready; restarting in ${delay}ms (attempt ${this.#restartAttempts})`);
107
+ this.#restartTimer = setTimeout(() => {
108
+ this.#restartTimer = null;
109
+ if (this.#manualStop) return;
110
+ this.#spawn(true);
111
+ }, delay);
55
112
  }
56
- send(message: BuilderMessage) {
57
- if (this.#proc) this.#proc.send(message);
58
- else this.logger.warn("incrementalBuilderHost is not running");
113
+ send(message: BuilderMessage): boolean {
114
+ if (!this.#proc || this.#status !== "ready") {
115
+ this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
116
+ return false;
117
+ }
118
+ try {
119
+ this.#proc.send(message);
120
+ return true;
121
+ } catch (error) {
122
+ this.logger.warn(
123
+ `failed to send ${message.type} to builder: ${error instanceof Error ? error.message : String(error)}`,
124
+ );
125
+ return false;
126
+ }
59
127
  }
60
128
  stop() {
129
+ this.#manualStop = true;
130
+ if (this.#restartTimer) {
131
+ clearTimeout(this.#restartTimer);
132
+ this.#restartTimer = null;
133
+ }
61
134
  if (this.#proc) this.#proc.kill();
62
135
  this.#proc = null;
63
136
  this.ready = false;
137
+ this.#status = "stopped";
64
138
  }
65
139
  static async create(app: App, env: Record<string, string>, onMessage: (message: BuilderMessage) => void) {
66
140
  const candidates = [
@@ -36,9 +36,10 @@ const resolveMobileTargetByBasePath = (
36
36
  const [template] = targets;
37
37
  if (!template) return undefined;
38
38
  return {
39
- name: template.name,
39
+ name: normalizedBasePath,
40
40
  config: {
41
41
  ...template.config,
42
+ name: normalizedBasePath,
42
43
  basePath: normalizedBasePath,
43
44
  },
44
45
  };
@@ -59,6 +59,7 @@ const appRootAllowedDirs = new Set([
59
59
  "env",
60
60
  "ios",
61
61
  "lib",
62
+ "mobile",
62
63
  "page",
63
64
  "private",
64
65
  "public",
@@ -1,11 +1,8 @@
1
1
  import type { QueryOf } from "akanjs/constant";
2
- import DataLoader from "dataloader";
3
- import { get, groupBy, keyBy } from "lodash";
4
2
 
5
3
  export const Id = String;
6
4
  export const ObjectId = String;
7
5
  export const Mixed = Object;
8
- export { DataLoader };
9
6
 
10
7
  type LoaderItem = Record<string, unknown>;
11
8
  type LoaderModel = {
@@ -13,6 +10,143 @@ type LoaderModel = {
13
10
  };
14
11
  type ArrayElementLoaderItem = LoaderItem & { key: unknown };
15
12
  type QueryRecord = Record<string, unknown>;
13
+ type BatchLoadFn<Key, Value> = (
14
+ keys: readonly Key[],
15
+ ) => PromiseLike<ReadonlyArray<Value | Error>> | ReadonlyArray<Value | Error>;
16
+
17
+ interface DataLoaderOptions<Key, CacheKey> {
18
+ cache?: boolean;
19
+ cacheKeyFn?: (key: Key) => CacheKey;
20
+ batch?: boolean;
21
+ batchScheduleFn?: (callback: () => void) => void;
22
+ maxBatchSize?: number;
23
+ name?: string;
24
+ }
25
+
26
+ interface BatchItem<Key, Value> {
27
+ key: Key;
28
+ resolve: (value: Value) => void;
29
+ reject: (reason: unknown) => void;
30
+ }
31
+
32
+ /** Minimal DataLoader-compatible batch loader used by Akan document resolvers. */
33
+ export class DataLoader<Key, Value, CacheKey = Key> {
34
+ readonly name?: string;
35
+ readonly #batchLoadFn: BatchLoadFn<Key, Value>;
36
+ readonly #cache: boolean;
37
+ readonly #cacheKeyFn: (key: Key) => CacheKey;
38
+ readonly #batch: boolean;
39
+ readonly #batchScheduleFn: (callback: () => void) => void;
40
+ readonly #maxBatchSize: number;
41
+ readonly #promiseCache = new Map<CacheKey, Promise<Value>>();
42
+ #queue: BatchItem<Key, Value>[] = [];
43
+ #scheduled = false;
44
+
45
+ constructor(batchLoadFn: BatchLoadFn<Key, Value>, options: DataLoaderOptions<Key, CacheKey> = {}) {
46
+ this.#batchLoadFn = batchLoadFn;
47
+ this.#cache = options.cache !== false;
48
+ this.#cacheKeyFn = options.cacheKeyFn ?? ((key) => key as unknown as CacheKey);
49
+ this.#batch = options.batch !== false;
50
+ this.#batchScheduleFn = options.batchScheduleFn ?? ((callback) => queueMicrotask(callback));
51
+ this.#maxBatchSize = options.maxBatchSize ?? Number.POSITIVE_INFINITY;
52
+ this.name = options.name;
53
+ }
54
+
55
+ load(key: Key): Promise<Value> {
56
+ const cacheKey = this.#cacheKeyFn(key);
57
+ if (this.#cache) {
58
+ const cached = this.#promiseCache.get(cacheKey);
59
+ if (cached) return cached;
60
+ }
61
+
62
+ const promise = new Promise<Value>((resolve, reject) => {
63
+ this.#queue.push({ key, resolve, reject });
64
+ if (this.#batch) this.#schedule();
65
+ else this.#dispatch();
66
+ });
67
+ if (this.#cache) this.#promiseCache.set(cacheKey, promise);
68
+ return promise;
69
+ }
70
+
71
+ async loadMany(keys: readonly Key[]): Promise<Array<Value | Error>> {
72
+ const results = await Promise.allSettled(keys.map((key) => this.load(key)));
73
+ return results.map((result) => (result.status === "fulfilled" ? result.value : toError(result.reason)));
74
+ }
75
+
76
+ clear(key: Key): this {
77
+ this.#promiseCache.delete(this.#cacheKeyFn(key));
78
+ return this;
79
+ }
80
+
81
+ clearAll(): this {
82
+ this.#promiseCache.clear();
83
+ return this;
84
+ }
85
+
86
+ prime(key: Key, value: Value | Error): this {
87
+ if (!this.#cache) return this;
88
+ const cacheKey = this.#cacheKeyFn(key);
89
+ if (this.#promiseCache.has(cacheKey)) return this;
90
+ this.#promiseCache.set(cacheKey, value instanceof Error ? Promise.reject(value) : Promise.resolve(value));
91
+ return this;
92
+ }
93
+
94
+ #schedule() {
95
+ if (this.#scheduled) return;
96
+ this.#scheduled = true;
97
+ this.#batchScheduleFn(() => this.#dispatch());
98
+ }
99
+
100
+ #dispatch() {
101
+ this.#scheduled = false;
102
+ const batch = this.#queue.splice(0, this.#maxBatchSize);
103
+ if (this.#queue.length > 0) this.#schedule();
104
+ if (batch.length === 0) return;
105
+ const keys = batch.map(({ key }) => key);
106
+ Promise.resolve(this.#batchLoadFn(keys)).then(
107
+ (values) => {
108
+ if (values.length !== batch.length) {
109
+ const error = new Error(`DataLoader expected ${batch.length} values, received ${values.length}`);
110
+ batch.forEach(({ reject }) => {
111
+ reject(error);
112
+ });
113
+ return;
114
+ }
115
+ values.forEach((value, index) => {
116
+ if (value instanceof Error) batch[index]?.reject(value);
117
+ else batch[index]?.resolve(value as Value);
118
+ });
119
+ },
120
+ (error) => {
121
+ batch.forEach(({ reject }) => {
122
+ reject(error);
123
+ });
124
+ },
125
+ );
126
+ }
127
+ }
128
+
129
+ function toError(reason: unknown): Error {
130
+ return reason instanceof Error ? reason : new Error(String(reason));
131
+ }
132
+
133
+ function keyBy<T>(items: T[], keyOrGetter: keyof T | ((item: T) => unknown)): Record<string, T> {
134
+ const entries = items.map((item) => {
135
+ const key = typeof keyOrGetter === "function" ? keyOrGetter(item) : item[keyOrGetter];
136
+ return [String(key), item] as const;
137
+ });
138
+ return Object.fromEntries(entries);
139
+ }
140
+
141
+ function groupBy<T>(items: T[], getKey: (item: T) => unknown): Record<string, T[]> {
142
+ const groups: Record<string, T[]> = {};
143
+ for (const item of items) {
144
+ const key = String(getKey(item));
145
+ groups[key] ??= [];
146
+ groups[key].push(item);
147
+ }
148
+ return groups;
149
+ }
16
150
 
17
151
  const setQueryOperator = (query: QueryOf<unknown>, fieldName: string, op: "oneOf" | "has", value: unknown) => {
18
152
  (query as QueryRecord)[fieldName] = { kind: "op", op, value };
@@ -25,7 +159,7 @@ export const createLoader = <Key, Value>(model: LoaderModel, fieldName = "id", d
25
159
  setQueryOperator(query, fieldName, "oneOf", fields);
26
160
  const data = Promise.resolve(model.find(query)).then((list) => {
27
161
  const listByKey = keyBy(list, fieldName);
28
- return fields.map((id: unknown) => get(listByKey, String(id), null));
162
+ return fields.map((id: unknown) => listByKey[String(id)] ?? null);
29
163
  });
30
164
  return data as unknown as Promise<Value[]>;
31
165
  },
@@ -60,7 +194,7 @@ export const createArrayElementLoader = <K, V>(
60
194
  }));
61
195
  });
62
196
  const listByKey = groupBy(flat, (dat) => dat.key);
63
- return fields.map((id) => get(listByKey, String(id), null));
197
+ return fields.map((id) => listByKey[String(id)] ?? null);
64
198
  });
65
199
  return data as unknown as Promise<V[]>;
66
200
  },
@@ -80,7 +214,7 @@ export const createQueryLoader = <Key, Value>(
80
214
  queryKeys.map((key) => String((query as QueryRecord)[key])).join("");
81
215
  const data = Promise.resolve(model.find(query)).then((list) => {
82
216
  const listByKey = keyBy(list, getQueryKey);
83
- return queries.map((query: QueryOf<unknown>) => get(listByKey, getQueryKey(query), null));
217
+ return queries.map((query: QueryOf<unknown>) => listByKey[getQueryKey(query)] ?? null);
84
218
  });
85
219
  return data as unknown as Promise<Value[]>;
86
220
  },