akanjs 3.0.0-alpha.5 → 3.0.0-alpha.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/client/clientRuntime.ts +8 -0
  2. package/client/cn.ts +7 -0
  3. package/client/csrTypes.ts +2 -0
  4. package/client/frameDebug.ts +3 -2
  5. package/common/index.ts +5 -0
  6. package/common/routeConvention.ts +30 -0
  7. package/document/into.ts +2 -0
  8. package/fetch/client/fetchClient.ts +26 -2
  9. package/fetch/client/wsClient.ts +17 -2
  10. package/package.json +1 -1
  11. package/server/resolver/CascadeRunner.ts +1 -1
  12. package/server/resolver/database.resolver.ts +3 -0
  13. package/server/routeTreeBuilder.ts +2 -44
  14. package/server/webRouter.ts +96 -0
  15. package/store/types.ts +7 -0
  16. package/store/withSelector.ts +3 -5
  17. package/types/client/cn.d.ts +5 -0
  18. package/types/client/csrTypes.d.ts +2 -0
  19. package/types/common/index.d.ts +1 -1
  20. package/types/common/routeConvention.d.ts +8 -0
  21. package/types/document/into.d.ts +2 -0
  22. package/types/fetch/client/fetchClient.d.ts +1 -0
  23. package/types/store/baseSt.d.ts +1 -1
  24. package/types/store/types.d.ts +4 -0
  25. package/types/store/withSelector.d.ts +3 -5
  26. package/types/ui/CsrImage.d.ts +1 -1
  27. package/types/ui/Dropdown.d.ts +2 -0
  28. package/types/ui/Image.d.ts +3 -3
  29. package/types/ui/Layout/BottomInset.d.ts +2 -1
  30. package/types/ui/Layout/index.d.ts +1 -1
  31. package/types/ui/Signal/style.d.ts +1 -1
  32. package/types/ui/UiOverride/context.d.ts +12 -2
  33. package/types/ui/index.d.ts +2 -1
  34. package/types/ui/overlayLayer.d.ts +24 -0
  35. package/types/ui/recipe/badgeRecipe.d.ts +7 -3
  36. package/types/ui/recipe/buttonRecipe.d.ts +7 -3
  37. package/types/ui/recipe/inputRecipe.d.ts +5 -1
  38. package/types/webkit/index.d.ts +1 -0
  39. package/types/webkit/lazy.d.ts +12 -0
  40. package/types/webkit/useCsrValues.d.ts +3 -3
  41. package/types/webkit/useEscapeKey.d.ts +5 -0
  42. package/types/webkit/useFrameRuntime.d.ts +6 -1
  43. package/ui/Badge.tsx +2 -2
  44. package/ui/BottomSheet.tsx +5 -0
  45. package/ui/Button.tsx +3 -1
  46. package/ui/Constant/Doc.tsx +1 -1
  47. package/ui/CsrImage.tsx +1 -1
  48. package/ui/Data/ListContainer.tsx +1 -1
  49. package/ui/DatePicker.tsx +4 -3
  50. package/ui/Dialog/Modal.tsx +12 -15
  51. package/ui/DraggableList.tsx +3 -1
  52. package/ui/Dropdown.tsx +33 -11
  53. package/ui/Field.tsx +14 -8
  54. package/ui/Image.tsx +3 -3
  55. package/ui/Input.tsx +7 -5
  56. package/ui/Layout/BottomInset.tsx +6 -1
  57. package/ui/Loading/ProgressBar.tsx +8 -1
  58. package/ui/Menu.tsx +7 -8
  59. package/ui/Model/EditModal.tsx +37 -2
  60. package/ui/Model/SureToRemove.tsx +2 -1
  61. package/ui/Model/index_.tsx +42 -15
  62. package/ui/ObjectId.tsx +3 -4
  63. package/ui/Pagination.tsx +3 -4
  64. package/ui/Popconfirm.tsx +8 -7
  65. package/ui/Select.tsx +4 -3
  66. package/ui/System/CSR.tsx +6 -2
  67. package/ui/ToggleSelect.tsx +4 -3
  68. package/ui/Tooltip.tsx +2 -1
  69. package/ui/UiOverride/context.ts +12 -2
  70. package/ui/index.ts +8 -1
  71. package/ui/overlayLayer.ts +39 -0
  72. package/ui/recipe/badgeRecipe.ts +22 -3
  73. package/ui/recipe/buttonRecipe.ts +51 -2
  74. package/ui/recipe/factory.ts +2 -2
  75. package/ui/recipe/inputRecipe.ts +18 -4
  76. package/webkit/bootCsr.tsx +2 -33
  77. package/webkit/index.ts +1 -0
  78. package/webkit/lazy.tsx +22 -2
  79. package/webkit/useCsrValues.ts +121 -4
  80. package/webkit/useEscapeKey.tsx +42 -0
  81. package/webkit/useFrameRuntime.ts +65 -32
@@ -75,6 +75,13 @@ globalWithRuntime[CLIENT_RUNTIME_KEY] = state;
75
75
  const missingRuntimeError = () =>
76
76
  new Error("Akan client runtime is not registered. Import the generated app client first.");
77
77
 
78
+ const applyRuntimeErrorConstructor = (runtime: ClientRuntime) => {
79
+ const instance = (runtime.fetch as RuntimeFetch | undefined)?.instance as
80
+ | { setErrorConstructor?: (Err: unknown) => void }
81
+ | undefined;
82
+ if (typeof instance?.setErrorConstructor === "function") instance.setErrorConstructor(runtime.Err);
83
+ };
84
+
78
85
  export const registerClientRuntime = <Runtime>(
79
86
  runtime: Runtime,
80
87
  { scope = "app" }: { scope?: RuntimeScope } = {},
@@ -82,6 +89,7 @@ export const registerClientRuntime = <Runtime>(
82
89
  if (state.scope === "app" && scope === "lib") return runtime;
83
90
  state.runtime = runtime as ClientRuntime;
84
91
  state.scope = scope;
92
+ applyRuntimeErrorConstructor(state.runtime);
85
93
  return runtime;
86
94
  };
87
95
 
package/client/cn.ts CHANGED
@@ -38,10 +38,17 @@ export const colorTokens = [
38
38
  "ring",
39
39
  ];
40
40
 
41
+ /**
42
+ * Akan's semantic radius tokens (`--radius-box` / `--radius-field` in ui/styles.css). Without them
43
+ * `cn("rounded-field", "rounded-full")` keeps both classes and stylesheet order decides the winner.
44
+ */
45
+ export const radiusTokens = ["box", "field"];
46
+
41
47
  const twMerge = extendTailwindMerge({
42
48
  extend: {
43
49
  theme: {
44
50
  color: colorTokens,
51
+ radius: radiusTokens,
45
52
  },
46
53
  },
47
54
  });
@@ -455,6 +455,7 @@ export interface FrameLayoutState {
455
455
  keyboard: KeyboardFrameState;
456
456
  contentViewport: FrameContentViewportState;
457
457
  keyboardAccessory: KeyboardAccessoryFrameState;
458
+ contentAnchor?: "bottom";
458
459
  platformProfile: FramePlatformProfile;
459
460
  zIndex: FrameLayerZIndex;
460
461
  pageStateByPath: Map<string, PageState>;
@@ -463,6 +464,7 @@ export interface FrameSlotRegistration {
463
464
  scope?: FrameSlotScope;
464
465
  type: FrameSlotType;
465
466
  role?: FrameSlotRole;
467
+ contentAnchor?: "bottom";
466
468
  height?: number;
467
469
  estimatedHeight?: number;
468
470
  source?: "navbar" | "topInset" | "bottomInset" | "bottomTab" | (string & {});
@@ -20,9 +20,10 @@ const isFrameDebugEnabled = () => {
20
20
  export function debugFrame(event: string, payload: DebugPayload = {}) {
21
21
  if (!isFrameDebugEnabled()) return;
22
22
  debugSeq += 1;
23
- console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, {
23
+ const details = {
24
24
  href: window.location.href,
25
25
  now: Math.round(performance.now()),
26
26
  ...payload,
27
- });
27
+ };
28
+ console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, details, JSON.stringify(details));
28
29
  }
package/common/index.ts CHANGED
@@ -36,12 +36,17 @@ export { randomPicks } from "./randomPicks";
36
36
  export {
37
37
  assertUniqueRoutePatterns,
38
38
  compareRouteSpecificity,
39
+ getRouteExports,
39
40
  isRouteSourceFile,
40
41
  isSpecialRouteLeaf,
42
+ LAYOUT_ROUTE_EXPORTS,
41
43
  matchRoutePattern,
42
44
  normalizeRoutePattern,
45
+ PAGE_ROUTE_EXPORTS,
43
46
  type ParsedRouteModuleKey,
44
47
  parseRouteModuleKey,
48
+ RESERVED_ROUTE_CONFIG_EXPORTS,
49
+ ROOT_LAYOUT_ROUTE_EXPORTS,
45
50
  type RouteModuleKind,
46
51
  routeSegmentToPatternPart,
47
52
  routeSegmentToTreePath,
@@ -9,6 +9,36 @@ const DIRECTORY_SCOPED_LEAVES = new Set(["_layout", "_index", "_overrides"]);
9
9
 
10
10
  export type RouteModuleKind = "page" | "layout" | "overrides";
11
11
 
12
+ export const PAGE_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
13
+ "default",
14
+ "pageConfig",
15
+ "head",
16
+ "metadata",
17
+ "generateHead",
18
+ "generateMetadata",
19
+ "Loading",
20
+ ]);
21
+ export const LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([...PAGE_ROUTE_EXPORTS, "NotFound", "Error"]);
22
+ export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
23
+ ...LAYOUT_ROUTE_EXPORTS,
24
+ "fonts",
25
+ "manifest",
26
+ "theme",
27
+ "reconnect",
28
+ "wsConnect",
29
+ "layoutStyle",
30
+ "gaTrackingId",
31
+ ]);
32
+ /** Root-layout exports that are plain config rather than components, so a PascalCase check cannot allow them. */
33
+ export const RESERVED_ROUTE_CONFIG_EXPORTS: ReadonlySet<string> = new Set(
34
+ [...ROOT_LAYOUT_ROUTE_EXPORTS].filter((name) => name !== "default" && !/^[A-Z]/.test(name)),
35
+ );
36
+
37
+ export function getRouteExports(kind: "page" | "layout", { rootLayout = false } = {}): ReadonlySet<string> {
38
+ if (kind === "page") return PAGE_ROUTE_EXPORTS;
39
+ return rootLayout ? ROOT_LAYOUT_ROUTE_EXPORTS : LAYOUT_ROUTE_EXPORTS;
40
+ }
41
+
12
42
  export interface ParsedRouteModuleKey {
13
43
  key: string;
14
44
  kind: RouteModuleKind;
package/document/into.ts CHANGED
@@ -88,6 +88,8 @@ export type Mdl<
88
88
  updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>): Promise<UpdateResult>;
89
89
  removeOne(query: _RawQuery): Promise<UpdateResult>;
90
90
  removeMany(query: _RawQuery): Promise<UpdateResult>;
91
+ updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
92
+ removeById(id: string): Promise<UpdateResult>;
91
93
  bulkWrite(operations: BulkWriteOperation<Raw, _RawDoc, _RawQuery>[]): Promise<UpdateResult>;
92
94
  /** @deprecated Renamed to `count`. */
93
95
  countDocuments(query: _RawQuery): Promise<number>;
@@ -37,6 +37,16 @@ export type FetchProxy<
37
37
  FetchClient &
38
38
  FetchType & { slice: SliceMetaObj; instance: FetchClient; _FetchType: FetchType; _SliceMetaObj: SliceMetaObj };
39
39
 
40
+ interface SharedClientState {
41
+ proxy: FetchProxy | null;
42
+ origin: string | null;
43
+ }
44
+
45
+ const SHARED_CLIENT_KEY = Symbol.for("akanjs.fetch.sharedClient");
46
+ const globalWithSharedClient = globalThis as typeof globalThis & { [SHARED_CLIENT_KEY]?: SharedClientState };
47
+ const sharedClientState: SharedClientState = globalWithSharedClient[SHARED_CLIENT_KEY] ?? { proxy: null, origin: null };
48
+ globalWithSharedClient[SHARED_CLIENT_KEY] = sharedClientState;
49
+
40
50
  type ClientSignalMap<SigType extends { fetch: any }> = {
41
51
  [K in keyof SigType as SigType[K] extends DatabaseSignal<any, any, any, any>
42
52
  ? K
@@ -80,6 +90,19 @@ export class FetchClient {
80
90
  FetchClient.#sharedSerializedSignal = {};
81
91
  FetchClient.#sharedRegistryVersion++;
82
92
  }
93
+ static resetSharedClient() {
94
+ sharedClientState.proxy = null;
95
+ sharedClientState.origin = null;
96
+ }
97
+ static #resolveSharedClientProxy(origin: string, Err?: ErrorConstructor) {
98
+ if (typeof window === "undefined") return null;
99
+
100
+ if (sharedClientState.proxy) return sharedClientState.origin === origin ? sharedClientState.proxy : null;
101
+ const proxy = FetchClient.#makeProxy<unknown, Record<string, SliceMeta>>(new FetchClient(origin, {}, {}, Err));
102
+ sharedClientState.proxy = proxy;
103
+ sharedClientState.origin = origin;
104
+ return proxy;
105
+ }
83
106
  static #mergeSerializedSignalInto(
84
107
  serializedSignal: { [key: string]: SerializedSignal },
85
108
  refName: string,
@@ -700,10 +723,11 @@ export class FetchClient {
700
723
  sig: ClientSignalMap<SigType>;
701
724
  fetch: SigType["fetch"];
702
725
  } {
703
- if (base) base.instance.applySignal(serializedSignal);
726
+ const shared = base ?? FetchClient.#resolveSharedClientProxy(origin, Err);
727
+ if (shared) shared.instance.applySignal(serializedSignal);
704
728
  if (base && Err) base.instance.setErrorConstructor(Err);
705
729
  const proxy =
706
- base ??
730
+ shared ??
707
731
  FetchClient.#makeProxy<unknown, Record<string, SliceMeta>>(new FetchClient(origin, {}, serializedSignal, Err));
708
732
  if (connect) proxy.instance.connect();
709
733
  const sig = {} as any;
@@ -40,6 +40,8 @@ export class WsClient {
40
40
  #roomSubscribeMap = new Map<string, SubscribeOption>();
41
41
  #listenerMap = new Map<string, Set<Listener>>();
42
42
  #destroyed = false;
43
+ #connectRequested = false;
44
+ #unconnectedWarnTimers = new Map<string, ReturnType<typeof setTimeout>>();
43
45
  #jwt: string | null = null;
44
46
  connected = false;
45
47
 
@@ -70,6 +72,7 @@ export class WsClient {
70
72
  }
71
73
 
72
74
  connect() {
75
+ this.#connectRequested = true;
73
76
  if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) return;
74
77
  this.logger.debug(`Connecting to ${this.url}`);
75
78
  this.#destroyed = false;
@@ -187,10 +190,13 @@ export class WsClient {
187
190
  destroy() {
188
191
  this.logger.debug(`WebSocket destroying`);
189
192
  this.#destroyed = true;
193
+ this.#connectRequested = false;
190
194
  if (this.#reconnectTimer) {
191
195
  clearTimeout(this.#reconnectTimer);
192
196
  this.#reconnectTimer = null;
193
197
  }
198
+ for (const timer of this.#unconnectedWarnTimers.values()) clearTimeout(timer);
199
+ this.#unconnectedWarnTimers.clear();
194
200
  this.#ws?.close();
195
201
  this.#ws = null;
196
202
  }
@@ -231,9 +237,18 @@ export class WsClient {
231
237
  }
232
238
  #warnNotConnected(action: "emit" | "subscribe", key: string) {
233
239
  console.warn(
234
- `[akanjs] WebSocket is not connected. Call fetch.instance.connect() or enable root layout "wsConnect" before ${action} "${key}".`,
240
+ `[akanjs] WebSocket is not connected. Call fetch.instance.connect(), or drop the root layout "wsConnect = false", before ${action} "${key}".`,
235
241
  );
236
242
  }
243
+ #warnUnconnectedSubscribe(key: string) {
244
+ if (this.#connectRequested || this.#unconnectedWarnTimers.has(key)) return;
245
+ const timer = setTimeout(() => {
246
+ this.#unconnectedWarnTimers.delete(key);
247
+ if (this.#connectRequested || this.#destroyed) return;
248
+ this.#warnNotConnected("subscribe", key);
249
+ }, 0);
250
+ this.#unconnectedWarnTimers.set(key, timer);
251
+ }
237
252
  emit(key: string, data: WsRequestPayload) {
238
253
  if (this.#ws?.readyState !== WebSocket.OPEN) {
239
254
  this.logger.warn("WebSocket not connected");
@@ -246,7 +261,7 @@ export class WsClient {
246
261
  }
247
262
  subscribe(option: { key: string; data: unknown[]; handleEvent: (data: unknown) => void }) {
248
263
  const roomId = WsClient.makeRoomId(option.key, option.data);
249
- if (!this.#ws) this.#warnNotConnected("subscribe", option.key);
264
+ if (!this.#ws) this.#warnUnconnectedSubscribe(option.key);
250
265
  if (!this.#roomSubscribeMap.has(roomId)) {
251
266
  this.#roomSubscribeMap.set(roomId, { key: option.key, data: option.data, listener: new Set() });
252
267
  if (this.#ws?.readyState === WebSocket.OPEN) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.5",
3
+ "version": "3.0.0-alpha.7",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -186,7 +186,7 @@ export class CascadeRunner {
186
186
  }
187
187
  if (!lines.length) return;
188
188
  const bulk = lines.filter((line) => line.endsWith("(bulk)")).length;
189
- this.#logger.info(`${lines.length} cascade edge(s), ${bulk} in one query`);
189
+ this.#logger.verbose(`${lines.length} cascade edge(s), ${bulk} in one query`);
190
190
  for (const line of lines) this.#logger.verbose(line);
191
191
  }
192
192
 
@@ -240,6 +240,9 @@ export class DatabaseResolver {
240
240
  updateMany: (query: QueryOf<any>, update: DocumentUpdateInput) => store.updateManyByQuery(query, update),
241
241
  removeOne: (query: QueryOf<any>) => store.removeOneByQuery(query),
242
242
  removeMany: (query: QueryOf<any>) => store.removeManyByQuery(query),
243
+ updateById: (id: string, update: DocumentUpdateInput, options?: { upsert?: boolean }) =>
244
+ store.updateOneByQuery({ id }, update, options),
245
+ removeById: (id: string) => store.removeOneByQuery({ id }),
243
246
 
244
247
  countDocuments: (query: QueryOf<any>) => store.count(query),
245
248
  bulkWrite: (
@@ -13,6 +13,7 @@ import type {
13
13
  import {
14
14
  assertUniqueRoutePatterns,
15
15
  compareRouteSpecificity,
16
+ getRouteExports,
16
17
  matchRoutePattern,
17
18
  parseBasePaths,
18
19
  parseRouteModuleKey,
@@ -49,44 +50,6 @@ export interface RouteModuleCacheStats {
49
50
  }
50
51
 
51
52
  export class RouteTreeBuilder {
52
- static readonly #pageRouteExports = new Set([
53
- "default",
54
- "pageConfig",
55
- "head",
56
- "metadata",
57
- "generateHead",
58
- "generateMetadata",
59
- "Loading",
60
- ]);
61
- static readonly #rootLayoutExports = new Set([
62
- "default",
63
- "pageConfig",
64
- "head",
65
- "metadata",
66
- "generateHead",
67
- "generateMetadata",
68
- "fonts",
69
- "manifest",
70
- "theme",
71
- "reconnect",
72
- "wsConnect",
73
- "layoutStyle",
74
- "gaTrackingId",
75
- "Loading",
76
- "NotFound",
77
- "Error",
78
- ]);
79
- static readonly #layoutRouteExports = new Set([
80
- "default",
81
- "pageConfig",
82
- "head",
83
- "metadata",
84
- "generateHead",
85
- "generateMetadata",
86
- "Loading",
87
- "NotFound",
88
- "Error",
89
- ]);
90
53
  static readonly #moduleCacheStats: RouteModuleCacheStats = {
91
54
  moduleCount: 0,
92
55
  loadedModuleCount: 0,
@@ -309,12 +272,7 @@ export class RouteTreeBuilder {
309
272
  return;
310
273
  }
311
274
  const parsed = parseRouteModuleKey(key);
312
- const allowed =
313
- kind === "page"
314
- ? RouteTreeBuilder.#pageRouteExports
315
- : parsed.isInternalRootLayout
316
- ? RouteTreeBuilder.#rootLayoutExports
317
- : RouteTreeBuilder.#layoutRouteExports;
275
+ const allowed = getRouteExports(kind, { rootLayout: parsed.isInternalRootLayout });
318
276
  for (const exportName of Object.keys(mod)) {
319
277
  if (!allowed.has(exportName)) {
320
278
  throw new Error(`[route-convention] unsupported export "${exportName}" in ${key}`);
@@ -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 { getEnv } from "akanjs/base";
4
5
  import {
5
6
  type AkanI18nConfig,
@@ -61,6 +62,18 @@ export const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES = 2 * 1024 * 1024;
61
62
  const ROUTE_CACHE_SWEEP_INTERVAL_MS = 60_000;
62
63
  const APPLE_APP_SITE_ASSOCIATION_PATH = "/.well-known/apple-app-site-association";
63
64
  const ANDROID_ASSET_LINKS_PATH = "/.well-known/assetlinks.json";
65
+ const FIREBASE_MESSAGING_SW_PATH = "/firebase-messaging-sw.js";
66
+ const FIREBASE_WEB_SDK_VERSION = "12.13.0";
67
+
68
+ interface FirebaseClientEnvConfig {
69
+ apiKey: string;
70
+ authDomain?: string;
71
+ projectId: string;
72
+ storageBucket?: string;
73
+ messagingSenderId: string;
74
+ appId: string;
75
+ vapidKey?: string;
76
+ }
64
77
 
65
78
  export function createRscRedirectResponse(
66
79
  location: string,
@@ -488,6 +501,16 @@ export class WebRouter {
488
501
  WebRouter.#deepLinkAssociationResponse(ANDROID_ASSET_LINKS_PATH, this.#artifact, {
489
502
  cacheControl: this.#prodMode ? "public, max-age=3600" : "no-store",
490
503
  }) ?? new Response("Not Found", { status: 404 }),
504
+ [FIREBASE_MESSAGING_SW_PATH]: async () => {
505
+ this.#requestStats.staticAsset += 1;
506
+ const firebaseConfig = await WebRouter.#resolveFirebaseClientConfig();
507
+ return new Response(WebRouter.#createFirebaseMessagingServiceWorker(firebaseConfig), {
508
+ headers: {
509
+ "Content-Type": "application/javascript; charset=utf-8",
510
+ "Cache-Control": "no-store",
511
+ },
512
+ });
513
+ },
491
514
  "/*": async (req) => {
492
515
  const url = new URL(req.url);
493
516
  if (WebRouter.#isImageOptimizerPath(url.pathname)) {
@@ -1008,6 +1031,79 @@ export class WebRouter {
1008
1031
  return process.env.AKAN_APP_DIR ?? path.dirname(Bun.main);
1009
1032
  }
1010
1033
 
1034
+ static async #resolveFirebaseClientConfig(): Promise<FirebaseClientEnvConfig | null> {
1035
+ const envPath = path.join(WebRouter.#resolveAppDir(), "env", "env.client.ts");
1036
+ if (!fs.existsSync(envPath)) return null;
1037
+ try {
1038
+ const envUrl = pathToFileURL(envPath);
1039
+ envUrl.searchParams.set("t", String(Date.now()));
1040
+ const envModule = (await import(envUrl.href)) as { env?: { firebase?: unknown } };
1041
+ return WebRouter.#normalizeFirebaseClientConfig(envModule.env?.firebase);
1042
+ } catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+
1047
+ static #normalizeFirebaseClientConfig(config: unknown): FirebaseClientEnvConfig | null {
1048
+ if (!config || typeof config !== "object") return null;
1049
+ const value = config as Partial<Record<keyof FirebaseClientEnvConfig, unknown>>;
1050
+ if (
1051
+ typeof value.apiKey !== "string" ||
1052
+ typeof value.projectId !== "string" ||
1053
+ typeof value.messagingSenderId !== "string" ||
1054
+ typeof value.appId !== "string"
1055
+ ) {
1056
+ return null;
1057
+ }
1058
+ return {
1059
+ apiKey: value.apiKey,
1060
+ ...(typeof value.authDomain === "string" ? { authDomain: value.authDomain } : {}),
1061
+ projectId: value.projectId,
1062
+ ...(typeof value.storageBucket === "string" ? { storageBucket: value.storageBucket } : {}),
1063
+ messagingSenderId: value.messagingSenderId,
1064
+ appId: value.appId,
1065
+ };
1066
+ }
1067
+
1068
+ static #createFirebaseMessagingServiceWorker(config: FirebaseClientEnvConfig | null): string {
1069
+ const configJson = JSON.stringify(config);
1070
+ return `/* Generated by Akan.js. Do not edit. */
1071
+ const firebaseConfig = ${configJson};
1072
+
1073
+ if (firebaseConfig) {
1074
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-app-compat.js");
1075
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-messaging-compat.js");
1076
+
1077
+ firebase.initializeApp(firebaseConfig);
1078
+ const messaging = firebase.messaging();
1079
+
1080
+ const notificationUrl = (payload) =>
1081
+ payload?.data?.url || payload?.fcmOptions?.link || payload?.notification?.click_action;
1082
+
1083
+ messaging.onBackgroundMessage((payload) => {
1084
+ const title = payload?.notification?.title || "";
1085
+ const options = {
1086
+ body: payload?.notification?.body,
1087
+ icon: payload?.notification?.icon,
1088
+ image: payload?.notification?.image,
1089
+ data: {
1090
+ url: notificationUrl(payload),
1091
+ FCM_MSG: payload,
1092
+ },
1093
+ };
1094
+ self.registration.showNotification(title, options);
1095
+ });
1096
+ }
1097
+
1098
+ self.addEventListener("notificationclick", (event) => {
1099
+ const url = event.notification?.data?.url || event.notification?.data?.FCM_MSG?.data?.url;
1100
+ event.notification?.close();
1101
+ if (!url) return;
1102
+ event.waitUntil(clients.openWindow(url));
1103
+ });
1104
+ `;
1105
+ }
1106
+
1011
1107
  static #normalizeArtifact(artifact: BaseBuildArtifact, artifactDir: string): BaseBuildArtifact {
1012
1108
  const normalizedArtifactDir = path.resolve(artifactDir);
1013
1109
  const pagesBundlePath = WebRouter.#resolveArtifactPath(artifact.pagesBundlePath, normalizedArtifactDir);
package/store/types.ts CHANGED
@@ -36,6 +36,13 @@ export type Get<State, Actions> = {
36
36
  get: () => State & Actions;
37
37
  };
38
38
 
39
+ type VoidAction<T> = T extends (...args: infer Args) => infer Ret
40
+ ? [Ret] extends [PromiseLike<unknown>]
41
+ ? (...args: Args) => Promise<void>
42
+ : (...args: Args) => void
43
+ : T;
44
+ export type VoidActions<Action> = { [K in keyof Action]: VoidAction<Action[K]> };
45
+
39
46
  export type StoreSliceMap<SlceCls extends SliceCls> = SlceCls[typeof SLICE_META];
40
47
  export type StoreSliceSuffix<SlceCls extends SliceCls, Suffix extends keyof StoreSliceMap<SlceCls>> = Suffix & string;
41
48
  export type StoreSliceSuffixCap<SlceCls extends SliceCls, Suffix extends keyof StoreSliceMap<SlceCls>> = Capitalize<
@@ -2,7 +2,7 @@ import type { Prettify } from "akanjs/base";
2
2
  import type { FieldState } from "akanjs/constant";
3
3
  import type { RefObject } from "react";
4
4
  import type { RootStoreCls } from "./rootStore";
5
- import type { SliceStateAction } from "./types";
5
+ import type { SliceStateAction, VoidActions } from "./types";
6
6
 
7
7
  type SetKey<Key extends string> = `set${Capitalize<Key>}`;
8
8
 
@@ -28,7 +28,7 @@ type WithSelectorsOf<State, WritableState, Action, InternalSliceObj> = {
28
28
  use: {
29
29
  [K in keyof State]: () => State[K];
30
30
  };
31
- do: Action & {
31
+ do: VoidActions<Action> & {
32
32
  [K in keyof WritableState as K extends string ? SetKey<K> : never]: (value: FieldState<WritableState[K]>) => void;
33
33
  };
34
34
  get: () => State;
@@ -52,9 +52,7 @@ export interface SliceSelectors<RefName extends string, State, Action> {
52
52
  [K in keyof State]: () => State[K];
53
53
  };
54
54
  do: Prettify<
55
- {
56
- [K in keyof Action]: Action[K];
57
- } & {
55
+ VoidActions<Action> & {
58
56
  [K in keyof State as K extends string ? SetKey<K> : never]: (value: FieldState<State[K]>) => void;
59
57
  }
60
58
  >;
@@ -6,6 +6,11 @@ import { type ClassNameValue } from "tailwind-merge";
6
6
  * `cn("bg-primary", "bg-open")` correctly resolves to `"bg-open"` instead of keeping both.
7
7
  */
8
8
  export declare const colorTokens: string[];
9
+ /**
10
+ * Akan's semantic radius tokens (`--radius-box` / `--radius-field` in ui/styles.css). Without them
11
+ * `cn("rounded-field", "rounded-full")` keeps both classes and stylesheet order decides the winner.
12
+ */
13
+ export declare const radiusTokens: string[];
9
14
  /** The one class-combining function: joins strings/arrays/conditional parts (`cond && "x"`) and
10
15
  * resolves Tailwind conflicts with a shared tailwind-merge instance that knows Akan's semantic
11
16
  * color tokens. clsx-style object syntax (`{ x: cond }`) is not supported — write `cond && "x"`. */
@@ -428,6 +428,7 @@ export interface FrameLayoutState {
428
428
  keyboard: KeyboardFrameState;
429
429
  contentViewport: FrameContentViewportState;
430
430
  keyboardAccessory: KeyboardAccessoryFrameState;
431
+ contentAnchor?: "bottom";
431
432
  platformProfile: FramePlatformProfile;
432
433
  zIndex: FrameLayerZIndex;
433
434
  pageStateByPath: Map<string, PageState>;
@@ -436,6 +437,7 @@ export interface FrameSlotRegistration {
436
437
  scope?: FrameSlotScope;
437
438
  type: FrameSlotType;
438
439
  role?: FrameSlotRole;
440
+ contentAnchor?: "bottom";
439
441
  height?: number;
440
442
  estimatedHeight?: number;
441
443
  source?: "navbar" | "topInset" | "bottomInset" | "bottomTab" | (string & {});
@@ -23,7 +23,7 @@ export { pathGet } from "./pathGet.d.ts";
23
23
  export { pathSet } from "./pathSet.d.ts";
24
24
  export { randomPick } from "./randomPick.d.ts";
25
25
  export { randomPicks } from "./randomPicks.d.ts";
26
- export { assertUniqueRoutePatterns, compareRouteSpecificity, isRouteSourceFile, isSpecialRouteLeaf, matchRoutePattern, normalizeRoutePattern, type ParsedRouteModuleKey, parseRouteModuleKey, type RouteModuleKind, routeSegmentToPatternPart, routeSegmentToTreePath, tryParseRouteModuleKey, type ValidatePageSourceFileOptions, type ValidateSubRoutePageKeyOptions, validatePageSourceFile, validateSubRoutePageKey, } from "./routeConvention.d.ts";
26
+ export { assertUniqueRoutePatterns, compareRouteSpecificity, getRouteExports, isRouteSourceFile, isSpecialRouteLeaf, LAYOUT_ROUTE_EXPORTS, matchRoutePattern, normalizeRoutePattern, PAGE_ROUTE_EXPORTS, type ParsedRouteModuleKey, parseRouteModuleKey, RESERVED_ROUTE_CONFIG_EXPORTS, ROOT_LAYOUT_ROUTE_EXPORTS, type RouteModuleKind, routeSegmentToPatternPart, routeSegmentToTreePath, tryParseRouteModuleKey, type ValidatePageSourceFileOptions, type ValidateSubRoutePageKeyOptions, validatePageSourceFile, validateSubRoutePageKey, } from "./routeConvention.d.ts";
27
27
  export { sleep } from "./sleep.d.ts";
28
28
  export { splitVersion } from "./splitVersion.d.ts";
29
29
  export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute.d.ts";
@@ -1,4 +1,12 @@
1
1
  export type RouteModuleKind = "page" | "layout" | "overrides";
2
+ export declare const PAGE_ROUTE_EXPORTS: ReadonlySet<string>;
3
+ export declare const LAYOUT_ROUTE_EXPORTS: ReadonlySet<string>;
4
+ export declare const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string>;
5
+ /** Root-layout exports that are plain config rather than components, so a PascalCase check cannot allow them. */
6
+ export declare const RESERVED_ROUTE_CONFIG_EXPORTS: ReadonlySet<string>;
7
+ export declare function getRouteExports(kind: "page" | "layout", { rootLayout }?: {
8
+ rootLayout?: boolean | undefined;
9
+ }): ReadonlySet<string>;
2
10
  export interface ParsedRouteModuleKey {
3
11
  key: string;
4
12
  kind: RouteModuleKind;
@@ -62,6 +62,8 @@ export type Mdl<Doc, Raw, _RawDoc = DocumentModel<Raw>, _RawQuery extends Docume
62
62
  updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>): Promise<UpdateResult>;
63
63
  removeOne(query: _RawQuery): Promise<UpdateResult>;
64
64
  removeMany(query: _RawQuery): Promise<UpdateResult>;
65
+ updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
66
+ removeById(id: string): Promise<UpdateResult>;
65
67
  bulkWrite(operations: BulkWriteOperation<Raw, _RawDoc, _RawQuery>[]): Promise<UpdateResult>;
66
68
  /** @deprecated Renamed to `count`. */
67
69
  countDocuments(query: _RawQuery): Promise<number>;
@@ -35,6 +35,7 @@ export declare class FetchClient {
35
35
  [key: string]: SerializedSignal;
36
36
  }, ErrorCls?: ErrorConstructor | undefined);
37
37
  static resetSharedRegistry(): void;
38
+ static resetSharedClient(): void;
38
39
  setErrorConstructor(ErrorCls?: ErrorConstructor): void;
39
40
  applySignal(serializedSignal: {
40
41
  [key: string]: SerializedSignal;
@@ -297,7 +297,7 @@ export declare const st: {
297
297
  deviceToken: () => string;
298
298
  currentPath: () => string;
299
299
  };
300
- do: RootStore & {
300
+ do: import("./types.d.ts").VoidActions<RootStore> & {
301
301
  setCsrLoaded: (value: boolean) => void;
302
302
  setPath: (value: string) => void;
303
303
  setPathname: (value: string) => void;
@@ -30,6 +30,10 @@ export interface SetPick<State = any> {
30
30
  export type Get<State, Actions> = {
31
31
  get: () => State & Actions;
32
32
  };
33
+ type VoidAction<T> = T extends (...args: infer Args) => infer Ret ? [Ret] extends [PromiseLike<unknown>] ? (...args: Args) => Promise<void> : (...args: Args) => void : T;
34
+ export type VoidActions<Action> = {
35
+ [K in keyof Action]: VoidAction<Action[K]>;
36
+ };
33
37
  export type StoreSliceMap<SlceCls extends SliceCls> = SlceCls[typeof SLICE_META];
34
38
  export type StoreSliceSuffix<SlceCls extends SliceCls, Suffix extends keyof StoreSliceMap<SlceCls>> = Suffix & string;
35
39
  export type StoreSliceSuffixCap<SlceCls extends SliceCls, Suffix extends keyof StoreSliceMap<SlceCls>> = Capitalize<StoreSliceSuffix<SlceCls, Suffix>>;
@@ -2,7 +2,7 @@ import type { Prettify } from "akanjs/base";
2
2
  import type { FieldState } from "akanjs/constant";
3
3
  import type { RefObject } from "react";
4
4
  import type { RootStoreCls } from "./rootStore.d.ts";
5
- import type { SliceStateAction } from "./types.d.ts";
5
+ import type { SliceStateAction, VoidActions } from "./types.d.ts";
6
6
  type SetKey<Key extends string> = `set${Capitalize<Key>}`;
7
7
  export type WithSelectors<RtStoreCls extends RootStoreCls> = RtStoreCls extends RootStoreCls<any, infer WritableState, infer Action, infer InternalSliceObj, any, any, infer State> ? WithSelectorsOf<State, WritableState, Action, InternalSliceObj> : never;
8
8
  type WithSelectorsOf<State, WritableState, Action, InternalSliceObj> = {
@@ -18,7 +18,7 @@ type WithSelectorsOf<State, WritableState, Action, InternalSliceObj> = {
18
18
  use: {
19
19
  [K in keyof State]: () => State[K];
20
20
  };
21
- do: Action & {
21
+ do: VoidActions<Action> & {
22
22
  [K in keyof WritableState as K extends string ? SetKey<K> : never]: (value: FieldState<WritableState[K]>) => void;
23
23
  };
24
24
  get: () => State;
@@ -32,9 +32,7 @@ export interface SliceSelectors<RefName extends string, State, Action> {
32
32
  use: {
33
33
  [K in keyof State]: () => State[K];
34
34
  };
35
- do: Prettify<{
36
- [K in keyof Action]: Action[K];
37
- } & {
35
+ do: Prettify<VoidActions<Action> & {
38
36
  [K in keyof State as K extends string ? SetKey<K> : never]: (value: FieldState<State[K]>) => void;
39
37
  }>;
40
38
  get: () => State;
@@ -7,7 +7,7 @@ type CsrImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "alt" | "src"> &
7
7
  imageSize: [number, number];
8
8
  abstractData?: string | null;
9
9
  } | null;
10
- abstractData?: string;
10
+ abstractData?: string | null;
11
11
  priority?: boolean;
12
12
  preload?: boolean;
13
13
  quality?: number;
@@ -1,4 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
+ /** Put this on a menu item that runs its own interaction (a switch, a copy button) to keep the menu open. */
3
+ export declare const DROPDOWN_KEEP_OPEN_ATTR = "data-dropdown-keep-open";
2
4
  export interface DropdownProps {
3
5
  /** Button/trigger content. */
4
6
  value: ReactNode;