akanjs 2.3.11-rc.6 → 2.3.11-rc.8

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.
@@ -107,6 +107,19 @@ export type CapacitorPushNotificationsModule = {
107
107
  requestPermissions: () => Promise<{ receive: "granted" | "denied" | string }>;
108
108
  checkPermissions: () => Promise<{ receive: "granted" | "denied" | string }>;
109
109
  register: () => Promise<void> | void;
110
+ addListener: (
111
+ eventName:
112
+ | "registration"
113
+ | "registrationError"
114
+ | "pushNotificationReceived"
115
+ | "pushNotificationActionPerformed"
116
+ | string,
117
+ listenerFunc: (event: {
118
+ value?: string;
119
+ error?: string;
120
+ notification?: { data?: Record<string, unknown> };
121
+ }) => void,
122
+ ) => Promise<{ remove?: () => Promise<void> | void } | void> | { remove?: () => Promise<void> | void } | void;
110
123
  };
111
124
  };
112
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.3.11-rc.6",
3
+ "version": "2.3.11-rc.8",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -198,6 +198,7 @@
198
198
  "cordova-plugin-purchase": "^13.16.0",
199
199
  "croner": "^10.0.1",
200
200
  "daisyui": "5.5.23",
201
+ "firebase": "^12.13.0",
201
202
  "ioredis": "^5.10.1",
202
203
  "mermaid": "^11.15.0",
203
204
  "postgres": "^3.4.9",
@@ -288,6 +289,9 @@
288
289
  "daisyui": {
289
290
  "optional": true
290
291
  },
292
+ "firebase": {
293
+ "optional": true
294
+ },
291
295
  "ioredis": {
292
296
  "optional": true
293
297
  },
@@ -49,7 +49,12 @@ export class LocaleWebProxy implements WebProxy {
49
49
  (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
50
50
  );
51
51
 
52
- if (!isInternalProxyRequest(requestUrl) && !isWellKnownRequest(pathname) && pathnameIsMissingLocale) {
52
+ if (
53
+ !isInternalProxyRequest(requestUrl) &&
54
+ !isWellKnownRequest(pathname) &&
55
+ !isApiRequest(pathname) &&
56
+ pathnameIsMissingLocale
57
+ ) {
53
58
  return Response.redirect(
54
59
  new URL(`/${getLocale(request)}/${pathname.slice(1)}${targetUrl.search}`, getPublicRequestUrl(request)),
55
60
  307,
@@ -90,3 +95,7 @@ function isInternalProxyRequest(requestUrl: URL): boolean {
90
95
  function isWellKnownRequest(pathname: string): boolean {
91
96
  return pathname === "/.well-known" || pathname.startsWith("/.well-known/");
92
97
  }
98
+
99
+ function isApiRequest(pathname: string): boolean {
100
+ return pathname === "/api" || pathname.startsWith("/api/");
101
+ }
@@ -158,6 +158,16 @@ export class SignalResolver {
158
158
  const serviceName = `${refName}Service`;
159
159
  const capitalizedRefName = capitalize(refName);
160
160
 
161
+ const assertSliceQuery = (query: unknown, key: string) => {
162
+ if (Array.isArray(query))
163
+ throw new Error(
164
+ `Slice "${refName}.${key}" exec returned an array instead of a query descriptor. ` +
165
+ `Return a query from the slice's service (e.g. this.${refName}Service.queryBy...(...)), ` +
166
+ `not an executed list (listBy.../findMany...), which resolves to an array.`,
167
+ );
168
+ return query;
169
+ };
170
+
161
171
  class SliceEndpoint extends sliceEndpoint(sliceCls.srv, (builder) => {
162
172
  const endpointObj: { [key: string]: EndpointInfo } = {};
163
173
  Object.entries(sliceMeta).forEach(([key, sliceInfo]) => {
@@ -179,7 +189,10 @@ export class SignalResolver {
179
189
  const limit = Number(requestArgs[argLength + 1] ?? 20);
180
190
  const sort = requestArgs[argLength + 2] ?? "latest";
181
191
  const internalArgs = requestArgs.slice(argLength + 3);
182
- const query = await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]);
192
+ const query = assertSliceQuery(
193
+ await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]),
194
+ key,
195
+ );
183
196
  return (await this[serviceName].__list(query, {
184
197
  skip,
185
198
  limit,
@@ -196,7 +209,10 @@ export class SignalResolver {
196
209
  .exec(async function (this: any, ...requestArgs: any) {
197
210
  const args = requestArgs.slice(0, argLength);
198
211
  const internalArgs = requestArgs.slice(argLength);
199
- const query = await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]);
212
+ const query = assertSliceQuery(
213
+ await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]),
214
+ key,
215
+ );
200
216
  return await this[serviceName].__insight(query);
201
217
  });
202
218
  });
@@ -816,7 +816,15 @@ class QueryCompiler {
816
816
  private assertPath(path: string) {
817
817
  const root = path.split(".")[0];
818
818
  if (BASE_COLUMNS.has(root)) return;
819
- if (!this.fields[root]) throw new Error(`Unknown document field path: ${path}`);
819
+ if (!this.fields[root]) {
820
+
821
+ if (/^\d+$/.test(root))
822
+ throw new Error(
823
+ `Query received an array instead of a query object (field path "${path}"). ` +
824
+ `A query must be a descriptor object; a slice exec must return queryBy...(...), not an executed list.`,
825
+ );
826
+ throw new Error(`Unknown document field path: ${path}`);
827
+ }
820
828
  }
821
829
 
822
830
  private isQueryNode(value: unknown): value is DocumentQueryNode {
@@ -164,6 +164,17 @@ export type CapacitorPushNotificationsModule = {
164
164
  receive: "granted" | "denied" | string;
165
165
  }>;
166
166
  register: () => Promise<void> | void;
167
+ addListener: (eventName: "registration" | "registrationError" | "pushNotificationReceived" | "pushNotificationActionPerformed" | string, listenerFunc: (event: {
168
+ value?: string;
169
+ error?: string;
170
+ notification?: {
171
+ data?: Record<string, unknown>;
172
+ };
173
+ }) => void) => Promise<{
174
+ remove?: () => Promise<void> | void;
175
+ } | void> | {
176
+ remove?: () => Promise<void> | void;
177
+ } | void;
167
178
  };
168
179
  };
169
180
  export type CapacitorSafeAreaModule = {
@@ -13,5 +13,5 @@ export { useGeoLocation } from "./useGeoLocation.d.ts";
13
13
  export { useHistory } from "./useHistory.d.ts";
14
14
  export { useInterval } from "./useInterval.d.ts";
15
15
  export { useLocation } from "./useLocation.d.ts";
16
- export { usePushNoti } from "./usePushNoti.d.ts";
16
+ export { initPushNotificationClickBridge, type PushNotificationPermission, type PushNotificationPlatform, type PushNotificationProvider, type PushToken, usePushNotification, } from "./usePushNotification.d.ts";
17
17
  export { useThrottle } from "./useThrottle.d.ts";
@@ -0,0 +1,28 @@
1
+ import { type FirebaseOptions } from "firebase/app";
2
+ export type PushNotificationPlatform = "web" | "ios" | "android";
3
+ export type PushNotificationProvider = "fcm";
4
+ export interface PushToken {
5
+ token: string;
6
+ platform: PushNotificationPlatform;
7
+ provider: PushNotificationProvider;
8
+ deviceId?: string;
9
+ }
10
+ export type PushNotificationPermission = "prompt" | "granted" | "denied" | string;
11
+ export interface PushNotificationClientEnv {
12
+ firebase?: FirebaseOptions & {
13
+ vapidKey?: string;
14
+ };
15
+ }
16
+ declare global {
17
+ var __AKAN_PUSH_CLICK_BRIDGE__: Promise<boolean> | undefined;
18
+ var __AKAN_CLIENT_ENV__: PushNotificationClientEnv | undefined;
19
+ }
20
+ export declare const initPushNotificationClickBridge: () => Promise<boolean>;
21
+ export declare const usePushNotification: () => {
22
+ isSupported: () => Promise<boolean>;
23
+ getPermission: () => Promise<PushNotificationPermission>;
24
+ requestPermission: () => Promise<PushNotificationPermission>;
25
+ register: () => Promise<PushToken | undefined>;
26
+ getToken: () => Promise<PushToken | undefined>;
27
+ initClickBridge: () => Promise<boolean>;
28
+ };
package/ui/System/CSR.tsx CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "akanjs/client";
16
16
  import { st } from "akanjs/store";
17
17
  import { animated } from "akanjs/ui";
18
- import { useFetch, usePushNoti } from "akanjs/webkit";
18
+ import { useFetch } from "akanjs/webkit";
19
19
  import { createElement, memo, type ComponentProps, type ReactNode, type RefObject, useEffect, useRef } from "react";
20
20
  import { createPortal } from "react-dom";
21
21
 
@@ -392,7 +392,6 @@ interface CSRBridgeProps {
392
392
  prefix?: string;
393
393
  }
394
394
  const CSRBridge = ({ lang, prefix = "" }: CSRBridgeProps) => {
395
- const pushNoti = usePushNoti();
396
395
  const { location, pageContentRef } = useCsr();
397
396
  useEffect(() => {
398
397
  const { path, pathname } = getPathInfo(location.pathname, lang, prefix);
@@ -408,13 +407,6 @@ const CSRBridge = ({ lang, prefix = "" }: CSRBridgeProps) => {
408
407
  const device = Device.getDevice();
409
408
  device.listenKeyboardChanged(st.do.setKeyboardHeight);
410
409
  device.setPageContentRef(pageContentRef);
411
- if (device.info.platform === "web") return;
412
- void (async () => {
413
- await pushNoti.init();
414
- const token = await pushNoti.getToken();
415
- if (!token) return;
416
- st.do.setDeviceToken(token);
417
- })();
418
410
  return () => {
419
411
  device.unlistenKeyboardChanged();
420
412
  };
@@ -206,6 +206,7 @@ interface ClientBridgeProps {
206
206
  }
207
207
 
208
208
  export const ClientBridge = ({ env, lang, theme, prefix, gaTrackingId, wsConnect = true }: ClientBridgeProps) => {
209
+ (globalThis as typeof globalThis & { __AKAN_CLIENT_ENV__?: ClientEnv }).__AKAN_CLIENT_ENV__ = env;
209
210
  const uiOperation = st.use.uiOperation();
210
211
  const pathname = st.use.pathname();
211
212
  const params = st.use.params();
package/webkit/index.ts CHANGED
@@ -13,5 +13,12 @@ export { useGeoLocation } from "./useGeoLocation";
13
13
  export { useHistory } from "./useHistory";
14
14
  export { useInterval } from "./useInterval";
15
15
  export { useLocation } from "./useLocation";
16
- export { usePushNoti } from "./usePushNoti";
16
+ export {
17
+ initPushNotificationClickBridge,
18
+ type PushNotificationPermission,
19
+ type PushNotificationPlatform,
20
+ type PushNotificationProvider,
21
+ type PushToken,
22
+ usePushNotification,
23
+ } from "./usePushNotification";
17
24
  export { useThrottle } from "./useThrottle";
@@ -0,0 +1,267 @@
1
+ "use client";
2
+
3
+ import { router } from "akanjs/client";
4
+ import { loadCapacitorDevice, loadCapacitorFcm, loadCapacitorPushNotifications } from "akanjs/client/capacitor";
5
+ import { getApps, initializeApp, type FirebaseOptions } from "firebase/app";
6
+ import { getMessaging, getToken as getFirebaseToken } from "firebase/messaging";
7
+ import { useEffect } from "react";
8
+
9
+ export type PushNotificationPlatform = "web" | "ios" | "android";
10
+ export type PushNotificationProvider = "fcm";
11
+
12
+ export interface PushToken {
13
+ token: string;
14
+ platform: PushNotificationPlatform;
15
+ provider: PushNotificationProvider;
16
+ deviceId?: string;
17
+ }
18
+
19
+ export type PushNotificationPermission = "prompt" | "granted" | "denied" | string;
20
+
21
+ export interface PushNotificationClientEnv {
22
+ firebase?: FirebaseOptions & {
23
+ vapidKey?: string;
24
+ };
25
+ }
26
+
27
+ declare global {
28
+
29
+ var __AKAN_PUSH_CLICK_BRIDGE__: Promise<boolean> | undefined;
30
+
31
+ var __AKAN_CLIENT_ENV__: PushNotificationClientEnv | undefined;
32
+ }
33
+
34
+ const getClientEnv = () => globalThis.__AKAN_CLIENT_ENV__;
35
+
36
+ const getFirebaseConfig = () => getClientEnv()?.firebase;
37
+
38
+ const normalizePlatform = (platform: string): PushNotificationPlatform | null => {
39
+ if (platform === "web" || platform === "ios" || platform === "android") return platform;
40
+ return null;
41
+ };
42
+
43
+ const isWebRuntime = () => typeof window !== "undefined" && typeof navigator !== "undefined";
44
+
45
+ const isInternalDeepLink = (url: string) => {
46
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
47
+ if (!isWebRuntime()) return false;
48
+ try {
49
+ const parsed = new URL(url, window.location.origin);
50
+ return parsed.origin === window.location.origin;
51
+ } catch {
52
+ return false;
53
+ }
54
+ };
55
+
56
+ const enterDeepLink = (url: string) => {
57
+ if (!isInternalDeepLink(url)) return false;
58
+ const parsed = new URL(url, isWebRuntime() ? window.location.origin : "http://localhost");
59
+ return router.enterDeepLink(`${parsed.pathname}${parsed.search}${parsed.hash}`);
60
+ };
61
+
62
+ const getNativePlatform = async () => {
63
+ const { Device } = await loadCapacitorDevice();
64
+ const device = await Device.getInfo();
65
+ return normalizePlatform(device.platform);
66
+ };
67
+
68
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
69
+
70
+ const getNativeToken = async (options?: { retries?: number }): Promise<PushToken | undefined> => {
71
+ const [{ FCM }, platform] = await Promise.all([loadCapacitorFcm(), getNativePlatform()]);
72
+ if (!platform || platform === "web") return undefined;
73
+
74
+ const retries = options?.retries ?? 0;
75
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
76
+ const { token } = await FCM.getToken();
77
+ if (token) return { token, platform, provider: "fcm" };
78
+ if (attempt < retries) await sleep(500 * (attempt + 1));
79
+ }
80
+
81
+ return undefined;
82
+ };
83
+
84
+ const getWebToken = async (): Promise<PushToken | undefined> => {
85
+ if (!isWebRuntime() || !("serviceWorker" in navigator)) return undefined;
86
+ const firebaseConfig = getFirebaseConfig();
87
+ if (!firebaseConfig?.apiKey || !firebaseConfig.projectId || !firebaseConfig.messagingSenderId || !firebaseConfig.appId) {
88
+ return undefined;
89
+ }
90
+ const firebase = getApps()[0] ?? initializeApp(firebaseConfig);
91
+ const messaging = getMessaging(firebase);
92
+ const serviceWorkerRegistration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
93
+ const token = await getFirebaseToken(messaging, {
94
+ vapidKey: firebaseConfig.vapidKey,
95
+ serviceWorkerRegistration,
96
+ });
97
+ if (!token) return undefined;
98
+ return { token, platform: "web", provider: "fcm" };
99
+ };
100
+
101
+ const getPushUrlFromNativeEvent = (event: { notification?: { data?: Record<string, unknown> } }) => {
102
+ const data = event.notification?.data;
103
+ const fcmMessage = data?.FCM_MSG as { data?: { url?: unknown }; fcmOptions?: { link?: unknown } } | undefined;
104
+ const directUrl = data?.url;
105
+ const nestedUrl = fcmMessage?.data?.url ?? fcmMessage?.fcmOptions?.link;
106
+ return typeof directUrl === "string" ? directUrl : typeof nestedUrl === "string" ? nestedUrl : undefined;
107
+ };
108
+
109
+ const waitForNativeRegistration = async (
110
+ PushNotifications: Awaited<ReturnType<typeof loadCapacitorPushNotifications>>["PushNotifications"],
111
+ ): Promise<string | undefined> => {
112
+ let registrationHandle: { remove?: () => Promise<void> | void } | void;
113
+ let errorHandle: { remove?: () => Promise<void> | void } | void;
114
+
115
+ const cleanup = async () => {
116
+ await registrationHandle?.remove?.();
117
+ await errorHandle?.remove?.();
118
+ };
119
+
120
+ return await new Promise<string | undefined>((resolve) => {
121
+ let settled = false;
122
+ const finish = (token?: string) => {
123
+ if (settled) return;
124
+ settled = true;
125
+ clearTimeout(timeout);
126
+ void cleanup();
127
+ resolve(token);
128
+ };
129
+
130
+ const timeout = setTimeout(() => finish(), 8000);
131
+
132
+ Promise.resolve(
133
+ PushNotifications.addListener("registration", (event) => {
134
+ finish(typeof event.value === "string" ? event.value : undefined);
135
+ }),
136
+ ).then((handle) => {
137
+ registrationHandle = handle;
138
+ });
139
+ Promise.resolve(PushNotifications.addListener("registrationError", () => finish())).then((handle) => {
140
+ errorHandle = handle;
141
+ });
142
+ Promise.resolve(PushNotifications.register()).catch(() => finish());
143
+ });
144
+ };
145
+
146
+ export const initPushNotificationClickBridge = async () => {
147
+ if (globalThis.__AKAN_PUSH_CLICK_BRIDGE__) return await globalThis.__AKAN_PUSH_CLICK_BRIDGE__;
148
+
149
+ try {
150
+ const platform = await getNativePlatform();
151
+ if (!platform || platform === "web") return true;
152
+
153
+ globalThis.__AKAN_PUSH_CLICK_BRIDGE__ = (async () => {
154
+ const { PushNotifications } = await loadCapacitorPushNotifications();
155
+ await PushNotifications.addListener("pushNotificationActionPerformed", (event) => {
156
+ const url = getPushUrlFromNativeEvent(event);
157
+ if (!url) return;
158
+ try {
159
+ enterDeepLink(url);
160
+ } catch {
161
+ }
162
+ });
163
+ return true;
164
+ })();
165
+
166
+ return await globalThis.__AKAN_PUSH_CLICK_BRIDGE__;
167
+ } catch {
168
+ return false;
169
+ }
170
+ };
171
+
172
+ export const usePushNotification = () => {
173
+ useEffect(() => {
174
+ void initPushNotificationClickBridge();
175
+ }, []);
176
+
177
+ const isSupported = async () => {
178
+ if (!isWebRuntime()) return false;
179
+ try {
180
+ const platform = await getNativePlatform();
181
+ if (platform && platform !== "web") {
182
+ await Promise.all([loadCapacitorFcm(), loadCapacitorPushNotifications()]);
183
+ return true;
184
+ }
185
+ } catch {
186
+ }
187
+ return Boolean(getFirebaseConfig()?.apiKey && "Notification" in window && "serviceWorker" in navigator);
188
+ };
189
+
190
+ const getPermission = async (): Promise<PushNotificationPermission> => {
191
+ try {
192
+ const platform = await getNativePlatform();
193
+ if (platform && platform !== "web") {
194
+ const { PushNotifications } = await loadCapacitorPushNotifications();
195
+ const { receive } = await PushNotifications.checkPermissions();
196
+ return receive;
197
+ }
198
+ } catch {
199
+ }
200
+ if (!isWebRuntime() || !("Notification" in window)) return "denied";
201
+ return Notification.permission;
202
+ };
203
+
204
+ const requestPermission = async (): Promise<PushNotificationPermission> => {
205
+ try {
206
+ const platform = await getNativePlatform();
207
+ if (platform && platform !== "web") {
208
+ const { PushNotifications } = await loadCapacitorPushNotifications();
209
+ const { receive } = await PushNotifications.requestPermissions();
210
+ return receive;
211
+ }
212
+ } catch {
213
+ }
214
+ if (!isWebRuntime() || !("Notification" in window)) return "denied";
215
+ return await Notification.requestPermission();
216
+ };
217
+
218
+ const getToken = async () => {
219
+ try {
220
+ const platform = await getNativePlatform();
221
+ if (platform && platform !== "web") return await getNativeToken();
222
+ } catch {
223
+ }
224
+ try {
225
+ return await getWebToken();
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ };
230
+
231
+ const register = async () => {
232
+ try {
233
+ const permission = await requestPermission();
234
+
235
+ const platform = await getNativePlatform().catch(() => null);
236
+ if (platform && platform !== "web") {
237
+ const [{ FCM }, { PushNotifications }] = await Promise.all([
238
+ loadCapacitorFcm(),
239
+ loadCapacitorPushNotifications(),
240
+ ]);
241
+ await FCM.setAutoInit({ enabled: true });
242
+
243
+ if (platform === "android") {
244
+ return await getNativeToken({ retries: 5 });
245
+ }
246
+
247
+ if (permission !== "granted") return undefined;
248
+ await waitForNativeRegistration(PushNotifications);
249
+ return await getNativeToken({ retries: 5 });
250
+ }
251
+
252
+ if (permission !== "granted") return undefined;
253
+ return await getWebToken();
254
+ } catch {
255
+ return undefined;
256
+ }
257
+ };
258
+
259
+ return {
260
+ isSupported,
261
+ getPermission,
262
+ requestPermission,
263
+ register,
264
+ getToken,
265
+ initClickBridge: initPushNotificationClickBridge,
266
+ };
267
+ };
@@ -1,7 +0,0 @@
1
- /** Capacitor push/FCM hook for permission, registration, and token retrieval. */
2
- export declare const usePushNoti: () => {
3
- init: () => Promise<void>;
4
- checkPermission: () => Promise<boolean>;
5
- register: () => Promise<void>;
6
- getToken: () => Promise<string | undefined>;
7
- };
@@ -1,48 +0,0 @@
1
- "use client";
2
- import { loadCapacitorDevice, loadCapacitorFcm, loadCapacitorPushNotifications } from "akanjs/client/capacitor";
3
-
4
- /** Capacitor push/FCM hook for permission, registration, and token retrieval. */
5
- export const usePushNoti = () => {
6
- const init = async () => {
7
- const [{ Device }, { FCM }, { PushNotifications }] = await Promise.all([
8
- loadCapacitorDevice(),
9
- loadCapacitorFcm(),
10
- loadCapacitorPushNotifications(),
11
- ]);
12
- const device = await Device.getInfo();
13
- if (device.platform === "web") return;
14
- void FCM.setAutoInit({ enabled: true });
15
- void PushNotifications.requestPermissions().then(async (result) => {
16
- if (result.receive === "granted") {
17
- await PushNotifications.register();
18
- }
19
- });
20
- };
21
-
22
- const checkPermission = async () => {
23
- const { PushNotifications } = await loadCapacitorPushNotifications();
24
- const { receive } = await PushNotifications.checkPermissions();
25
- return receive === "granted";
26
- };
27
- const register = async () => {
28
- const [{ Device }, { PushNotifications }] = await Promise.all([
29
- loadCapacitorDevice(),
30
- loadCapacitorPushNotifications(),
31
- ]);
32
- const device = await Device.getInfo();
33
- if (device.platform === "web") return;
34
- const { receive } = await PushNotifications.checkPermissions();
35
-
36
- if (receive === "denied") location.assign("app-settings:");
37
- else await PushNotifications.register();
38
- };
39
- const getToken = async () => {
40
- const [{ Device }, { FCM }] = await Promise.all([loadCapacitorDevice(), loadCapacitorFcm()]);
41
- const device = await Device.getInfo();
42
- if (device.platform === "web") return;
43
- const { token } = await FCM.getToken();
44
- return token;
45
- };
46
-
47
- return { init, checkPermission, register, getToken };
48
- };