akanjs 2.3.13 → 2.4.0-rc.0
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.
- package/client/csrTypes.ts +21 -0
- package/client/frameConfig.ts +7 -1
- package/package.json +5 -1
- package/server/routeElementComposer.tsx +22 -3
- package/server/routeTreeBuilder.ts +5 -0
- package/server/rscWorker.tsx +11 -0
- package/server/rscWorkerCache.ts +2 -0
- package/server/ssrFromRscRenderer.tsx +2 -1
- package/server/ssrTypes.ts +14 -0
- package/server/webRouter.ts +1 -0
- package/signal/guard.ts +3 -3
- package/signal/signalContext.ts +5 -0
- package/store/baseSt.ts +1 -0
- package/types/client/csrTypes.d.ts +19 -0
- package/types/server/routeElementComposer.d.ts +7 -3
- package/types/server/rscWorkerCache.d.ts +2 -0
- package/types/server/ssrTypes.d.ts +14 -0
- package/types/signal/guard.d.ts +2 -2
- package/types/signal/signalContext.d.ts +1 -0
- package/types/ui/Load/Units.d.ts +1 -1
- package/types/ui/Model/LoadView.d.ts +10 -0
- package/types/ui/Model/index.d.ts +1 -0
- package/types/ui/Model/index_.d.ts +1 -0
- package/types/webkit/index.d.ts +4 -0
- package/types/webkit/useCamera.d.ts +19 -0
- package/types/webkit/useCodepush.d.ts +16 -0
- package/types/webkit/useContact.d.ts +11 -0
- package/types/webkit/useGeoLocation.d.ts +8 -0
- package/types/webkit/usePurchase.d.ts +19 -0
- package/ui/Load/Units.tsx +2 -2
- package/ui/Model/LoadView.tsx +11 -0
- package/ui/Model/index.ts +2 -0
- package/ui/Model/index_.tsx +1 -0
- package/webkit/index.ts +4 -0
- package/webkit/useCamera.tsx +103 -0
- package/webkit/useCodepush.tsx +99 -0
- package/webkit/useContact.tsx +52 -0
- package/webkit/useGeoLocation.tsx +24 -0
- package/webkit/usePurchase.tsx +156 -0
package/client/csrTypes.ts
CHANGED
|
@@ -16,6 +16,20 @@ export type PageSafeAreaConfig =
|
|
|
16
16
|
bottom?: boolean;
|
|
17
17
|
android?: "auto" | "edge-to-edge" | "none";
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* Server-render strategy for the initial full-document SSR pass.
|
|
21
|
+
* - `"stream"` (default): flush the shell — including any `Loading` Suspense
|
|
22
|
+
* fallback — as soon as it is ready, then stream the resolved page. Redirects
|
|
23
|
+
* decided in the shell still become real HTTP redirects; redirects thrown
|
|
24
|
+
* inside suspended content degrade to soft (client) redirects.
|
|
25
|
+
* - `"block"`: buffer the whole document until every Suspense boundary resolves
|
|
26
|
+
* before sending a byte. The `Loading` fallback never reaches the browser, but
|
|
27
|
+
* a non-redirect error thrown in slow content can still yield a clean error
|
|
28
|
+
* page. Use only for routes that need that guarantee and do not care about SEO
|
|
29
|
+
* or first-paint of the fallback.
|
|
30
|
+
*/
|
|
31
|
+
export type SsrRenderMode = "stream" | "block";
|
|
32
|
+
|
|
19
33
|
/** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
|
|
20
34
|
export interface PageConfig {
|
|
21
35
|
transition?: TransitionType;
|
|
@@ -26,6 +40,8 @@ export interface PageConfig {
|
|
|
26
40
|
bottomInset?: number | boolean;
|
|
27
41
|
gesture?: boolean;
|
|
28
42
|
cache?: boolean;
|
|
43
|
+
/** Initial full-document SSR strategy. Defaults to `"stream"`. */
|
|
44
|
+
ssr?: SsrRenderMode;
|
|
29
45
|
/**
|
|
30
46
|
* Opt in to guarded RSC page suffix commits when the page does not require
|
|
31
47
|
* head/metadata updates and the retained route chain head is invariant for
|
|
@@ -44,6 +60,7 @@ export interface CsrState {
|
|
|
44
60
|
bottomInset: number;
|
|
45
61
|
gesture: boolean;
|
|
46
62
|
cache: boolean;
|
|
63
|
+
ssr: SsrRenderMode;
|
|
47
64
|
topSafeAreaColor?: string;
|
|
48
65
|
bottomSafeAreaColor?: string;
|
|
49
66
|
}
|
|
@@ -99,6 +116,9 @@ export interface RouteRender {
|
|
|
99
116
|
render: LayoutRender | PageRender;
|
|
100
117
|
isAsync?: boolean;
|
|
101
118
|
Loading?: LayoutLoadingRender | PageLoadingRender;
|
|
119
|
+
/** Loads the module and populates `Loading` without running `render`/`resolveHead`.
|
|
120
|
+
* Used by the patch (suffix) compose path, which never calls `resolveHead`. */
|
|
121
|
+
resolveLoading?: () => void | Promise<void>;
|
|
102
122
|
NotFound?: LayoutNotFoundRender;
|
|
103
123
|
Error?: LayoutErrorRender;
|
|
104
124
|
resolveNotFound?: () => PromiseOrObject<LayoutNotFoundRender | undefined>;
|
|
@@ -242,6 +262,7 @@ export const defaultPageState: PageState = {
|
|
|
242
262
|
bottomInset: 0,
|
|
243
263
|
gesture: true,
|
|
244
264
|
cache: false,
|
|
265
|
+
ssr: "stream",
|
|
245
266
|
topSafeAreaColor: "var(--color-base-100, Canvas)",
|
|
246
267
|
bottomSafeAreaColor: "var(--color-base-100, Canvas)",
|
|
247
268
|
};
|
package/client/frameConfig.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PageConfig, PageSafeAreaConfig, PageState, TransitionType } from "./csrTypes";
|
|
1
|
+
import type { PageConfig, PageSafeAreaConfig, PageState, SsrRenderMode, TransitionType } from "./csrTypes";
|
|
2
2
|
|
|
3
3
|
export type DevicePlatform = "ios" | "android" | "web" | (string & {});
|
|
4
4
|
export type SafeAreaInsets = { top: number; bottom: number };
|
|
@@ -19,11 +19,13 @@ const pageConfigKeys = new Set<keyof PageConfig>([
|
|
|
19
19
|
"bottomInset",
|
|
20
20
|
"gesture",
|
|
21
21
|
"cache",
|
|
22
|
+
"ssr",
|
|
22
23
|
"rscPatchHeadSafe",
|
|
23
24
|
"topSafeAreaColor",
|
|
24
25
|
"bottomSafeAreaColor",
|
|
25
26
|
]);
|
|
26
27
|
const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
|
|
28
|
+
const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
|
|
27
29
|
const DEFAULT_BOOLEAN_INSET = 48;
|
|
28
30
|
|
|
29
31
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -43,6 +45,9 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
|
|
|
43
45
|
if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
|
|
44
46
|
throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
|
|
45
47
|
}
|
|
48
|
+
if (pageConfig.ssr !== undefined && !ssrRenderModes.has(pageConfig.ssr)) {
|
|
49
|
+
throw new Error(`[route-convention] unsupported pageConfig.ssr "${pageConfig.ssr}" in ${routeKey}`);
|
|
50
|
+
}
|
|
46
51
|
if (pageConfig.topInset !== undefined && !isValidInsetValue(pageConfig.topInset)) {
|
|
47
52
|
throw new Error(
|
|
48
53
|
`[route-convention] pageConfig.topInset in ${routeKey} must be a boolean or non-negative px number.`,
|
|
@@ -141,6 +146,7 @@ export function resolvePageState({
|
|
|
141
146
|
? false
|
|
142
147
|
: (config.gesture ?? false),
|
|
143
148
|
cache: config.cache ?? false,
|
|
149
|
+
ssr: config.ssr ?? "stream",
|
|
144
150
|
topSafeAreaColor: config.topSafeAreaColor ?? "var(--color-base-100, Canvas)",
|
|
145
151
|
bottomSafeAreaColor: config.bottomSafeAreaColor ?? "var(--color-base-100, Canvas)",
|
|
146
152
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -195,6 +195,7 @@
|
|
|
195
195
|
"bullmq": "^5.76.10",
|
|
196
196
|
"capacitor-plugin-safe-area": "^5.0.0",
|
|
197
197
|
"chance": "^1.1.13",
|
|
198
|
+
"cordova-plugin-purchase": "^13.16.0",
|
|
198
199
|
"croner": "^10.0.1",
|
|
199
200
|
"daisyui": "5.5.23",
|
|
200
201
|
"firebase": "^12.13.0",
|
|
@@ -279,6 +280,9 @@
|
|
|
279
280
|
"chance": {
|
|
280
281
|
"optional": true
|
|
281
282
|
},
|
|
283
|
+
"cordova-plugin-purchase": {
|
|
284
|
+
"optional": true
|
|
285
|
+
},
|
|
282
286
|
"croner": {
|
|
283
287
|
"optional": true
|
|
284
288
|
},
|
|
@@ -2,14 +2,14 @@ import type {
|
|
|
2
2
|
Head,
|
|
3
3
|
LayoutErrorRender,
|
|
4
4
|
LayoutFallbackRoute,
|
|
5
|
-
PageConfig,
|
|
6
5
|
LayoutNotFoundRender,
|
|
6
|
+
PageConfig,
|
|
7
7
|
PathRoute,
|
|
8
8
|
ResolvedHead,
|
|
9
9
|
RouteRender,
|
|
10
10
|
} from "akanjs/client";
|
|
11
|
-
import { getExplicitPageConfigKeys, resolvePageState } from "../client/frameConfig";
|
|
12
11
|
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, Suspense } from "react";
|
|
12
|
+
import { getExplicitPageConfigKeys, resolvePageState } from "../client/frameConfig";
|
|
13
13
|
import { resolveHeadResult } from "./metadata";
|
|
14
14
|
import { type AkanRouteSegmentState, createAkanRouteSegments, createAkanSegmentOutletKey } from "./routeState";
|
|
15
15
|
import { isAkanRscPartialCommitEnabled } from "./rscPartialCommit";
|
|
@@ -61,16 +61,19 @@ export class RouteElementComposer {
|
|
|
61
61
|
pathRoute,
|
|
62
62
|
params,
|
|
63
63
|
searchParams,
|
|
64
|
+
navKey,
|
|
64
65
|
}: {
|
|
65
66
|
pathRoute: PathRoute;
|
|
66
67
|
params: Record<string, string>;
|
|
67
68
|
searchParams: Record<string, string | string[]>;
|
|
69
|
+
navKey?: string;
|
|
68
70
|
}): ReactNode {
|
|
69
71
|
return RouteElementComposer.composeRenders({
|
|
70
72
|
renders: RouteElementComposer.#getRenderStack(pathRoute),
|
|
71
73
|
segments: isAkanRscPartialCommitEnabled() ? createAkanRouteSegments(pathRoute) : undefined,
|
|
72
74
|
params,
|
|
73
75
|
searchParams,
|
|
76
|
+
navKey,
|
|
74
77
|
});
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -79,11 +82,13 @@ export class RouteElementComposer {
|
|
|
79
82
|
params,
|
|
80
83
|
searchParams,
|
|
81
84
|
patchStartIndex,
|
|
85
|
+
navKey,
|
|
82
86
|
}: {
|
|
83
87
|
pathRoute: PathRoute;
|
|
84
88
|
params: Record<string, string>;
|
|
85
89
|
searchParams: Record<string, string | string[]>;
|
|
86
90
|
patchStartIndex: number;
|
|
91
|
+
navKey?: string;
|
|
87
92
|
}): ReactNode | null {
|
|
88
93
|
const renders = RouteElementComposer.#getRenderStack(pathRoute);
|
|
89
94
|
if (!Number.isInteger(patchStartIndex) || patchStartIndex < 0 || patchStartIndex >= renders.length) return null;
|
|
@@ -91,9 +96,18 @@ export class RouteElementComposer {
|
|
|
91
96
|
renders: renders.slice(patchStartIndex),
|
|
92
97
|
params,
|
|
93
98
|
searchParams,
|
|
99
|
+
navKey,
|
|
94
100
|
});
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
static async resolveSuffixLoadings(pathRoute: PathRoute, patchStartIndex: number): Promise<void> {
|
|
104
|
+
const renders = RouteElementComposer.#getRenderStack(pathRoute).slice(Math.max(patchStartIndex, 0));
|
|
105
|
+
|
|
106
|
+
await Promise.all(
|
|
107
|
+
renders.map((routeRender) => Promise.resolve(routeRender?.resolveLoading?.()).catch(() => undefined)),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
static async resolveHead({
|
|
98
112
|
pathRoute,
|
|
99
113
|
params,
|
|
@@ -170,18 +184,23 @@ export class RouteElementComposer {
|
|
|
170
184
|
segments,
|
|
171
185
|
params,
|
|
172
186
|
searchParams,
|
|
187
|
+
navKey,
|
|
173
188
|
}: {
|
|
174
189
|
renders: RouteRender[];
|
|
175
190
|
segments?: AkanRouteSegmentState[];
|
|
176
191
|
params: Record<string, string>;
|
|
177
192
|
searchParams: Record<string, string | string[]>;
|
|
193
|
+
navKey?: string;
|
|
178
194
|
}): ReactNode {
|
|
179
195
|
let element: ReactNode = null;
|
|
180
196
|
for (let i = renders.length - 1; i >= 0; i--) {
|
|
181
197
|
const routeRender = renders[i];
|
|
182
198
|
if (!routeRender) continue;
|
|
199
|
+
const loadingFallback = RouteElementComposer.#composeLoadingFallback(renders.slice(i), params);
|
|
200
|
+
const suspenseKey =
|
|
201
|
+
navKey && loadingFallback != null && i === renders.length - 1 ? `akan-loading:${navKey}` : undefined;
|
|
183
202
|
element = (
|
|
184
|
-
<Suspense fallback={
|
|
203
|
+
<Suspense key={suspenseKey} fallback={loadingFallback}>
|
|
185
204
|
<RouteElementComposer.AsyncRender routeRender={routeRender} params={params} searchParams={searchParams}>
|
|
186
205
|
{element}
|
|
187
206
|
</RouteElementComposer.AsyncRender>
|
|
@@ -34,6 +34,7 @@ export const defaultPageState: PageState = {
|
|
|
34
34
|
bottomInset: 0,
|
|
35
35
|
gesture: true,
|
|
36
36
|
cache: false,
|
|
37
|
+
ssr: "stream",
|
|
37
38
|
topSafeAreaColor: "transparent",
|
|
38
39
|
bottomSafeAreaColor: "transparent",
|
|
39
40
|
};
|
|
@@ -341,6 +342,10 @@ export class RouteTreeBuilder {
|
|
|
341
342
|
const loadModule = RouteTreeBuilder.#makeLazyModule(key, kind, loader);
|
|
342
343
|
const routeRender: RouteRender = {
|
|
343
344
|
isAsync: true,
|
|
345
|
+
resolveLoading: async () => {
|
|
346
|
+
const mod = await loadModule();
|
|
347
|
+
routeRender.Loading = mod.Loading as never;
|
|
348
|
+
},
|
|
344
349
|
render: async (props: LayoutProps | PageProps) => {
|
|
345
350
|
const mod = await loadModule();
|
|
346
351
|
routeRender.Loading = mod.Loading as never;
|
package/server/rscWorker.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AkanNotFoundError,
|
|
3
3
|
AkanRedirectError,
|
|
4
4
|
LayoutFallbackRoute,
|
|
5
|
+
PageState,
|
|
5
6
|
PathRoute,
|
|
6
7
|
RedirectStatus,
|
|
7
8
|
ResolvedHead,
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
import { type AkanI18nConfig, DEFAULT_AKAN_I18N, getBasePathFromPathname, Logger } from "akanjs/common";
|
|
10
11
|
import {
|
|
11
12
|
getRequestDynamicUsage,
|
|
13
|
+
getRequestFrameState,
|
|
12
14
|
getRequestPolicy,
|
|
13
15
|
getRequestTheme,
|
|
14
16
|
requestStorage,
|
|
@@ -525,6 +527,7 @@ export class RscRenderer {
|
|
|
525
527
|
patchStartSegment: undefined,
|
|
526
528
|
patchHeadSafe: undefined,
|
|
527
529
|
patchHeadSnapshot: undefined,
|
|
530
|
+
ssrBlocking: cached.ssrBlocking,
|
|
528
531
|
},
|
|
529
532
|
send: (message) => this.#send(message),
|
|
530
533
|
isCancelled: () => this.#cancelledRenderRequests.has(requestId),
|
|
@@ -553,9 +556,11 @@ export class RscRenderer {
|
|
|
553
556
|
else element = await this.#renderNotFound(urlObj);
|
|
554
557
|
const traceCacheKey =
|
|
555
558
|
effectivePatchDecision.status === "patch" ? (patchCacheEntry?.key ?? cacheEntry?.key) : cacheEntry?.key;
|
|
559
|
+
const ssrBlocking = getRequestFrameState<PageState>()?.ssr === "block";
|
|
556
560
|
const trace: RscTraceMetadata = {
|
|
557
561
|
...createTraceBase(effectivePatchDecision, traceCacheKey),
|
|
558
562
|
cache: cacheEntry ? "miss" : "bypass",
|
|
563
|
+
ssrBlocking,
|
|
559
564
|
};
|
|
560
565
|
this.#logger.verbose(`render[${requestId}] starting Flight stream`);
|
|
561
566
|
const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest, {
|
|
@@ -597,6 +602,7 @@ export class RscRenderer {
|
|
|
597
602
|
routeId,
|
|
598
603
|
tags: cacheState.tags,
|
|
599
604
|
theme: getRequestTheme(),
|
|
605
|
+
ssrBlocking,
|
|
600
606
|
cacheState,
|
|
601
607
|
patch: createCachedRscPatchMetadata({
|
|
602
608
|
targetRouterState,
|
|
@@ -616,6 +622,7 @@ export class RscRenderer {
|
|
|
616
622
|
routeId,
|
|
617
623
|
tags: cacheState.tags,
|
|
618
624
|
theme: getRequestTheme(),
|
|
625
|
+
ssrBlocking,
|
|
619
626
|
cacheState,
|
|
620
627
|
},
|
|
621
628
|
storeTtl,
|
|
@@ -1243,6 +1250,7 @@ export class RscRenderer {
|
|
|
1243
1250
|
pathRoute,
|
|
1244
1251
|
params: match.params,
|
|
1245
1252
|
searchParams,
|
|
1253
|
+
navKey: url.pathname + url.search,
|
|
1246
1254
|
});
|
|
1247
1255
|
return (
|
|
1248
1256
|
<html
|
|
@@ -1283,11 +1291,14 @@ export class RscRenderer {
|
|
|
1283
1291
|
basePath: this.#getBasePath(url),
|
|
1284
1292
|
});
|
|
1285
1293
|
setRequestFrameState(pathRoute.pageState);
|
|
1294
|
+
|
|
1295
|
+
await RouteElementComposer.resolveSuffixLoadings(pathRoute, patchStartIndex);
|
|
1286
1296
|
return RouteElementComposer.composeSuffix({
|
|
1287
1297
|
pathRoute,
|
|
1288
1298
|
params: match.params,
|
|
1289
1299
|
searchParams,
|
|
1290
1300
|
patchStartIndex,
|
|
1301
|
+
navKey: url.pathname + url.search,
|
|
1291
1302
|
});
|
|
1292
1303
|
}
|
|
1293
1304
|
|
package/server/rscWorkerCache.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface CachedRscResult {
|
|
|
16
16
|
routeId?: string;
|
|
17
17
|
tags?: string[];
|
|
18
18
|
theme?: string;
|
|
19
|
+
/** Whether the matched route opted into blocking SSR (`pageConfig.ssr: "block"`). */
|
|
20
|
+
ssrBlocking?: boolean;
|
|
19
21
|
cacheState: RouteCacheRenderState;
|
|
20
22
|
patch?: CachedRscPatchMetadata;
|
|
21
23
|
}
|
|
@@ -594,12 +594,13 @@ export class SsrFromRscRenderer {
|
|
|
594
594
|
? `${SsrFromRscRenderer.#clientBootstrap}\n${input.extraBootstrapInline}`
|
|
595
595
|
: SsrFromRscRenderer.#clientBootstrap;
|
|
596
596
|
|
|
597
|
+
const waitForAllReady = input.waitForAllReady || process.env.AKAN_SSR_WAIT_FOR_ALL_READY === "1";
|
|
597
598
|
const renderHtml = async () => {
|
|
598
599
|
const root = await thenable;
|
|
599
600
|
const stream = await renderToReadableStream(root, {
|
|
600
601
|
bootstrapScriptContent: bootstrap,
|
|
601
602
|
});
|
|
602
|
-
await stream.allReady;
|
|
603
|
+
if (waitForAllReady) await stream.allReady;
|
|
603
604
|
return stream;
|
|
604
605
|
};
|
|
605
606
|
const requestContext = input.requestStore ?? input.request;
|
package/server/ssrTypes.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface RscTraceMetadata {
|
|
|
42
42
|
patchHeadSafe?: boolean;
|
|
43
43
|
patchHeadSnapshot?: string;
|
|
44
44
|
routeState?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Resolved from the matched route's `pageConfig.ssr === "block"`. Rides the
|
|
47
|
+
* trace so the host (which never resolves pageConfig itself) can decide
|
|
48
|
+
* whether the full-document SSR pass buffers until every Suspense boundary
|
|
49
|
+
* resolves. Absent/`false` means stream the shell first.
|
|
50
|
+
*/
|
|
51
|
+
ssrBlocking?: boolean;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
export interface SsrFromRscInput {
|
|
@@ -73,4 +80,11 @@ export interface SsrFromRscInput {
|
|
|
73
80
|
injectThemeInitScript?: boolean;
|
|
74
81
|
lateControl?: Promise<SsrLateRedirect | null>;
|
|
75
82
|
onCancel?: (reason?: unknown) => void;
|
|
83
|
+
/**
|
|
84
|
+
* When true, buffer the whole document until every Suspense boundary resolves
|
|
85
|
+
* (`stream.allReady`) before emitting a byte, matching `pageConfig.ssr:
|
|
86
|
+
* "block"`. Defaults to shell-first streaming so `Loading` fallbacks surface.
|
|
87
|
+
* The `AKAN_SSR_WAIT_FOR_ALL_READY=1` env var forces blocking globally.
|
|
88
|
+
*/
|
|
89
|
+
waitForAllReady?: boolean;
|
|
76
90
|
}
|
package/server/webRouter.ts
CHANGED
|
@@ -578,6 +578,7 @@ export class WebRouter {
|
|
|
578
578
|
importmap: this.#artifact.vendorMap,
|
|
579
579
|
theme: themeCookieExists ? undefined : (rscResult.theme ?? "system"),
|
|
580
580
|
lateControl: rscResult.lateControl,
|
|
581
|
+
waitForAllReady: rscResult.trace?.ssrBlocking ?? false,
|
|
581
582
|
onCancel: (reason: unknown) => {
|
|
582
583
|
rscResult.cancel(reason);
|
|
583
584
|
},
|
package/signal/guard.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Cls } from "akanjs/base";
|
|
1
|
+
import type { Cls, PromiseOrObject } from "akanjs/base";
|
|
2
2
|
import type { SignalContext } from "./signalContext";
|
|
3
3
|
|
|
4
4
|
export interface Guard {
|
|
5
|
-
canPass(context: SignalContext): boolean
|
|
5
|
+
canPass(context: SignalContext): PromiseOrObject<boolean>;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export type GuardCls<Name extends string = string> = Cls<Guard, { readonly name: Name }>;
|
|
@@ -11,7 +11,7 @@ export type GuardCls<Name extends string = string> = Cls<Guard, { readonly name:
|
|
|
11
11
|
export const guard = <T extends string>(name: T): GuardCls<T> => {
|
|
12
12
|
return class Guard {
|
|
13
13
|
static name = name;
|
|
14
|
-
canPass(context: SignalContext): boolean {
|
|
14
|
+
canPass(context: SignalContext): PromiseOrObject<boolean> {
|
|
15
15
|
return true;
|
|
16
16
|
}
|
|
17
17
|
};
|
package/signal/signalContext.ts
CHANGED
|
@@ -99,6 +99,11 @@ export class SignalContext<
|
|
|
99
99
|
}
|
|
100
100
|
return instance as T;
|
|
101
101
|
}
|
|
102
|
+
getService<T>(refName: string): T {
|
|
103
|
+
const service = this.#live.service.get(refName);
|
|
104
|
+
if (!service) throw new Exception.Error(`Service "${refName}" not found in live registry`);
|
|
105
|
+
return service as T;
|
|
106
|
+
}
|
|
102
107
|
async init() {
|
|
103
108
|
if (this.trace) {
|
|
104
109
|
const start = performance.now();
|
package/store/baseSt.ts
CHANGED
|
@@ -44,6 +44,7 @@ export class BaseStore extends store("base" as const, () => ({
|
|
|
44
44
|
bottomInset: 0,
|
|
45
45
|
gesture: true,
|
|
46
46
|
cache: false,
|
|
47
|
+
ssr: "stream",
|
|
47
48
|
topSafeAreaColor: "var(--color-base-100, Canvas)",
|
|
48
49
|
bottomSafeAreaColor: "var(--color-base-100, Canvas)",
|
|
49
50
|
} as PageState,
|
|
@@ -10,6 +10,19 @@ export type PageSafeAreaConfig = boolean | "top" | "bottom" | {
|
|
|
10
10
|
bottom?: boolean;
|
|
11
11
|
android?: "auto" | "edge-to-edge" | "none";
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* Server-render strategy for the initial full-document SSR pass.
|
|
15
|
+
* - `"stream"` (default): flush the shell — including any `Loading` Suspense
|
|
16
|
+
* fallback — as soon as it is ready, then stream the resolved page. Redirects
|
|
17
|
+
* decided in the shell still become real HTTP redirects; redirects thrown
|
|
18
|
+
* inside suspended content degrade to soft (client) redirects.
|
|
19
|
+
* - `"block"`: buffer the whole document until every Suspense boundary resolves
|
|
20
|
+
* before sending a byte. The `Loading` fallback never reaches the browser, but
|
|
21
|
+
* a non-redirect error thrown in slow content can still yield a clean error
|
|
22
|
+
* page. Use only for routes that need that guarantee and do not care about SEO
|
|
23
|
+
* or first-paint of the fallback.
|
|
24
|
+
*/
|
|
25
|
+
export type SsrRenderMode = "stream" | "block";
|
|
13
26
|
/** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
|
|
14
27
|
export interface PageConfig {
|
|
15
28
|
transition?: TransitionType;
|
|
@@ -20,6 +33,8 @@ export interface PageConfig {
|
|
|
20
33
|
bottomInset?: number | boolean;
|
|
21
34
|
gesture?: boolean;
|
|
22
35
|
cache?: boolean;
|
|
36
|
+
/** Initial full-document SSR strategy. Defaults to `"stream"`. */
|
|
37
|
+
ssr?: SsrRenderMode;
|
|
23
38
|
/**
|
|
24
39
|
* Opt in to guarded RSC page suffix commits when the page does not require
|
|
25
40
|
* head/metadata updates and the retained route chain head is invariant for
|
|
@@ -37,6 +52,7 @@ export interface CsrState {
|
|
|
37
52
|
bottomInset: number;
|
|
38
53
|
gesture: boolean;
|
|
39
54
|
cache: boolean;
|
|
55
|
+
ssr: SsrRenderMode;
|
|
40
56
|
topSafeAreaColor?: string;
|
|
41
57
|
bottomSafeAreaColor?: string;
|
|
42
58
|
}
|
|
@@ -98,6 +114,9 @@ export interface RouteRender {
|
|
|
98
114
|
render: LayoutRender | PageRender;
|
|
99
115
|
isAsync?: boolean;
|
|
100
116
|
Loading?: LayoutLoadingRender | PageLoadingRender;
|
|
117
|
+
/** Loads the module and populates `Loading` without running `render`/`resolveHead`.
|
|
118
|
+
* Used by the patch (suffix) compose path, which never calls `resolveHead`. */
|
|
119
|
+
resolveLoading?: () => void | Promise<void>;
|
|
101
120
|
NotFound?: LayoutNotFoundRender;
|
|
102
121
|
Error?: LayoutErrorRender;
|
|
103
122
|
resolveNotFound?: () => PromiseOrObject<LayoutNotFoundRender | undefined>;
|
|
@@ -11,17 +11,20 @@ export declare class RouteElementComposer {
|
|
|
11
11
|
route: PathRoute | LayoutFallbackRoute;
|
|
12
12
|
basePath?: string | null;
|
|
13
13
|
}): Promise<import("akanjs/client").PageState>;
|
|
14
|
-
static compose({ pathRoute, params, searchParams, }: {
|
|
14
|
+
static compose({ pathRoute, params, searchParams, navKey, }: {
|
|
15
15
|
pathRoute: PathRoute;
|
|
16
16
|
params: Record<string, string>;
|
|
17
17
|
searchParams: Record<string, string | string[]>;
|
|
18
|
+
navKey?: string;
|
|
18
19
|
}): ReactNode;
|
|
19
|
-
static composeSuffix({ pathRoute, params, searchParams, patchStartIndex, }: {
|
|
20
|
+
static composeSuffix({ pathRoute, params, searchParams, patchStartIndex, navKey, }: {
|
|
20
21
|
pathRoute: PathRoute;
|
|
21
22
|
params: Record<string, string>;
|
|
22
23
|
searchParams: Record<string, string | string[]>;
|
|
23
24
|
patchStartIndex: number;
|
|
25
|
+
navKey?: string;
|
|
24
26
|
}): ReactNode | null;
|
|
27
|
+
static resolveSuffixLoadings(pathRoute: PathRoute, patchStartIndex: number): Promise<void>;
|
|
25
28
|
static resolveHead({ pathRoute, params, searchParams, }: {
|
|
26
29
|
pathRoute: PathRoute;
|
|
27
30
|
params: Record<string, string>;
|
|
@@ -41,11 +44,12 @@ export declare class RouteElementComposer {
|
|
|
41
44
|
error?: unknown;
|
|
42
45
|
digest?: string;
|
|
43
46
|
}): Promise<ReactNode | null>;
|
|
44
|
-
static composeRenders({ renders, segments, params, searchParams, }: {
|
|
47
|
+
static composeRenders({ renders, segments, params, searchParams, navKey, }: {
|
|
45
48
|
renders: RouteRender[];
|
|
46
49
|
segments?: AkanRouteSegmentState[];
|
|
47
50
|
params: Record<string, string>;
|
|
48
51
|
searchParams: Record<string, string | string[]>;
|
|
52
|
+
navKey?: string;
|
|
49
53
|
}): ReactNode;
|
|
50
54
|
static renderAsync({ routeRender, children, params, searchParams, }: {
|
|
51
55
|
routeRender: RouteRender;
|
|
@@ -8,6 +8,8 @@ export interface CachedRscResult {
|
|
|
8
8
|
routeId?: string;
|
|
9
9
|
tags?: string[];
|
|
10
10
|
theme?: string;
|
|
11
|
+
/** Whether the matched route opted into blocking SSR (`pageConfig.ssr: "block"`). */
|
|
12
|
+
ssrBlocking?: boolean;
|
|
11
13
|
cacheState: RouteCacheRenderState;
|
|
12
14
|
patch?: CachedRscPatchMetadata;
|
|
13
15
|
}
|
|
@@ -40,6 +40,13 @@ export interface RscTraceMetadata {
|
|
|
40
40
|
patchHeadSafe?: boolean;
|
|
41
41
|
patchHeadSnapshot?: string;
|
|
42
42
|
routeState?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Resolved from the matched route's `pageConfig.ssr === "block"`. Rides the
|
|
45
|
+
* trace so the host (which never resolves pageConfig itself) can decide
|
|
46
|
+
* whether the full-document SSR pass buffers until every Suspense boundary
|
|
47
|
+
* resolves. Absent/`false` means stream the shell first.
|
|
48
|
+
*/
|
|
49
|
+
ssrBlocking?: boolean;
|
|
43
50
|
}
|
|
44
51
|
export interface SsrFromRscInput {
|
|
45
52
|
request?: Request;
|
|
@@ -70,4 +77,11 @@ export interface SsrFromRscInput {
|
|
|
70
77
|
injectThemeInitScript?: boolean;
|
|
71
78
|
lateControl?: Promise<SsrLateRedirect | null>;
|
|
72
79
|
onCancel?: (reason?: unknown) => void;
|
|
80
|
+
/**
|
|
81
|
+
* When true, buffer the whole document until every Suspense boundary resolves
|
|
82
|
+
* (`stream.allReady`) before emitting a byte, matching `pageConfig.ssr:
|
|
83
|
+
* "block"`. Defaults to shell-first streaming so `Loading` fallbacks surface.
|
|
84
|
+
* The `AKAN_SSR_WAIT_FOR_ALL_READY=1` env var forces blocking globally.
|
|
85
|
+
*/
|
|
86
|
+
waitForAllReady?: boolean;
|
|
73
87
|
}
|
package/types/signal/guard.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Cls } from "akanjs/base";
|
|
1
|
+
import type { Cls, PromiseOrObject } from "akanjs/base";
|
|
2
2
|
import type { SignalContext } from "./signalContext.d.ts";
|
|
3
3
|
export interface Guard {
|
|
4
|
-
canPass(context: SignalContext): boolean
|
|
4
|
+
canPass(context: SignalContext): PromiseOrObject<boolean>;
|
|
5
5
|
}
|
|
6
6
|
export type GuardCls<Name extends string = string> = Cls<Guard, {
|
|
7
7
|
readonly name: Name;
|
|
@@ -30,6 +30,7 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
|
|
|
30
30
|
middleware: Map<string, MiddlewareCls>;
|
|
31
31
|
});
|
|
32
32
|
getAdaptor<T extends Adaptor>(adaptorCls: AdaptorCls<T>): T;
|
|
33
|
+
getService<T>(refName: string): T;
|
|
33
34
|
init(): Promise<this>;
|
|
34
35
|
exec(): Promise<Response | undefined>;
|
|
35
36
|
static try(endpoint: Adaptor, endpointInfo: EndpointInfo, key: string, fn: () => Promise<Response | undefined>): Promise<Response | undefined>;
|
package/types/ui/Load/Units.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ interface DefaultProps<L extends {
|
|
|
13
13
|
loading?: ReactNode;
|
|
14
14
|
filter?: (item: L, idx: number) => boolean;
|
|
15
15
|
sort?: (a: L, b: L) => number;
|
|
16
|
-
renderEmpty?: null | (() => ReactNode);
|
|
16
|
+
renderEmpty?: null | (() => ReactNode) | false;
|
|
17
17
|
renderItem?: (item: L, idx: number) => ReactNode;
|
|
18
18
|
renderList?: (list: DataList<L>) => ReactNode;
|
|
19
19
|
reverse?: boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ClientView } from "akanjs/fetch";
|
|
2
|
+
interface LoadViewProps<T extends string, Model extends {
|
|
3
|
+
id: string;
|
|
4
|
+
}> {
|
|
5
|
+
view: ClientView<T, Model>;
|
|
6
|
+
}
|
|
7
|
+
export default function LoadView<T extends string, Light extends {
|
|
8
|
+
id: string;
|
|
9
|
+
}>({ view }: LoadViewProps<T, Light>): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -8,6 +8,7 @@ export declare const Model: {
|
|
|
8
8
|
Remove: typeof import("./Remove.d.ts").default;
|
|
9
9
|
RemoveWrapper: typeof import("./RemoveWrapper.d.ts").default;
|
|
10
10
|
LoadInit: typeof import("./LoadInit.d.ts").default;
|
|
11
|
+
LoadView: typeof import("./LoadView.d.ts").default;
|
|
11
12
|
ViewWrapper: typeof import("./ViewWrapper.d.ts").default;
|
|
12
13
|
ViewEditModal: typeof import("./ViewEditModal.d.ts").default;
|
|
13
14
|
Edit: typeof import("./Edit.d.ts").default;
|
|
@@ -7,6 +7,7 @@ export declare const NewWrapper: typeof import("./NewWrapper.d.ts").default;
|
|
|
7
7
|
export declare const EditWrapper: typeof import("./EditWrapper.d.ts").default;
|
|
8
8
|
export declare const RemoveWrapper: typeof import("./RemoveWrapper.d.ts").default;
|
|
9
9
|
export declare const LoadInit: typeof import("./LoadInit.d.ts").default;
|
|
10
|
+
export declare const LoadView: typeof import("./LoadView.d.ts").default;
|
|
10
11
|
export declare const ViewWrapper: typeof import("./ViewWrapper.d.ts").default;
|
|
11
12
|
export declare const ViewEditModal: typeof import("./ViewEditModal.d.ts").default;
|
|
12
13
|
export declare const Edit: typeof import("./Edit.d.ts").default;
|
package/types/webkit/index.d.ts
CHANGED
|
@@ -3,9 +3,13 @@ export { createRobotPage } from "./createRobotPage.d.ts";
|
|
|
3
3
|
export { createSitemapPage } from "./createSitemapPage.d.ts";
|
|
4
4
|
export { lazy } from "./lazy.d.ts";
|
|
5
5
|
export type * from "./types.d.ts";
|
|
6
|
+
export { useCamera } from "./useCamera.d.ts";
|
|
7
|
+
export { useCodepush } from "./useCodepush.d.ts";
|
|
8
|
+
export { useContact } from "./useContact.d.ts";
|
|
6
9
|
export { useCsrValues } from "./useCsrValues.d.ts";
|
|
7
10
|
export { useDebounce } from "./useDebounce.d.ts";
|
|
8
11
|
export { useFetch, useFetchFn } from "./useFetch.d.ts";
|
|
12
|
+
export { useGeoLocation } from "./useGeoLocation.d.ts";
|
|
9
13
|
export { useHistory } from "./useHistory.d.ts";
|
|
10
14
|
export { useInterval } from "./useInterval.d.ts";
|
|
11
15
|
export { useLocation } from "./useLocation.d.ts";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CapacitorPermissionState } from "akanjs/client/capacitor";
|
|
2
|
+
type PermissionStatus = {
|
|
3
|
+
camera: CapacitorPermissionState;
|
|
4
|
+
photos: CapacitorPermissionState;
|
|
5
|
+
};
|
|
6
|
+
/** Capacitor camera/photos hook with permission checks and app-settings fallback. */
|
|
7
|
+
export declare const useCamera: () => {
|
|
8
|
+
permissions: PermissionStatus;
|
|
9
|
+
getPhoto: (src?: "prompt" | "camera" | "photos") => Promise<{
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
dataUrl?: string;
|
|
12
|
+
} | undefined>;
|
|
13
|
+
pickImage: () => Promise<{
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
photos: unknown[];
|
|
16
|
+
}>;
|
|
17
|
+
checkPermission: (type: "photos" | "camera" | "all") => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ProtoAppInfo, ProtoFile } from "akanjs/constant";
|
|
2
|
+
export declare const useCodepush: ({ serverUrl }: {
|
|
3
|
+
serverUrl: string;
|
|
4
|
+
}) => {
|
|
5
|
+
update: boolean;
|
|
6
|
+
version: string;
|
|
7
|
+
initialize: () => Promise<void>;
|
|
8
|
+
checkNewRelease: () => Promise<{
|
|
9
|
+
release: ProtoAppInfo & {
|
|
10
|
+
appBuild: string;
|
|
11
|
+
};
|
|
12
|
+
bundleFile: ProtoFile;
|
|
13
|
+
} | undefined>;
|
|
14
|
+
codepush: () => Promise<void>;
|
|
15
|
+
statManager: () => Promise<void>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type CapacitorPermissionState } from "akanjs/client/capacitor";
|
|
2
|
+
type PermissionStatus = {
|
|
3
|
+
contacts: CapacitorPermissionState;
|
|
4
|
+
};
|
|
5
|
+
/** Capacitor contacts hook with permission checks and contact loading helpers. */
|
|
6
|
+
export declare const useContact: () => {
|
|
7
|
+
permissions: PermissionStatus;
|
|
8
|
+
getContacts: () => Promise<unknown[]>;
|
|
9
|
+
checkPermission: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Capacitor geolocation hook with permission checks and current position lookup. */
|
|
2
|
+
export declare const useGeoLocation: () => {
|
|
3
|
+
checkPermission: () => Promise<{
|
|
4
|
+
geolocation: string;
|
|
5
|
+
coarseLocation: string;
|
|
6
|
+
}>;
|
|
7
|
+
getPosition: () => Promise<unknown>;
|
|
8
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import "cordova-plugin-purchase/www/store";
|
|
2
|
+
export type PlatformType = "android" | "ios" | "all";
|
|
3
|
+
export interface ProductType {
|
|
4
|
+
id: string;
|
|
5
|
+
type: keyof typeof CdvPurchase.ProductType;
|
|
6
|
+
}
|
|
7
|
+
export type CdvProductType = CdvPurchase.ProductType;
|
|
8
|
+
export declare const usePurchase: ({ platform, productInfo, url, onPay, onSubscribe, }: {
|
|
9
|
+
platform: PlatformType;
|
|
10
|
+
productInfo: ProductType[];
|
|
11
|
+
url: string;
|
|
12
|
+
onPay?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
|
|
13
|
+
onSubscribe?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
|
|
14
|
+
}) => {
|
|
15
|
+
isLoading: boolean;
|
|
16
|
+
products: CdvPurchase.Product[];
|
|
17
|
+
purchaseProduct: (product: CdvPurchase.Product) => Promise<void>;
|
|
18
|
+
restorePurchases: () => Promise<void>;
|
|
19
|
+
};
|
package/ui/Load/Units.tsx
CHANGED
|
@@ -23,7 +23,7 @@ interface DefaultProps<L extends { id: string }> {
|
|
|
23
23
|
loading?: ReactNode;
|
|
24
24
|
filter?: (item: L, idx: number) => boolean;
|
|
25
25
|
sort?: (a: L, b: L) => number;
|
|
26
|
-
renderEmpty?: null | (() => ReactNode);
|
|
26
|
+
renderEmpty?: null | (() => ReactNode) | false;
|
|
27
27
|
renderItem?: (item: L, idx: number) => ReactNode;
|
|
28
28
|
renderList?: (list: DataList<L>) => ReactNode;
|
|
29
29
|
reverse?: boolean;
|
|
@@ -172,7 +172,7 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
172
172
|
if (renderList)
|
|
173
173
|
return (
|
|
174
174
|
<>
|
|
175
|
-
{modelDataList.length ? (
|
|
175
|
+
{modelDataList.length || renderEmpty === false ? (
|
|
176
176
|
<ContainerWrapper
|
|
177
177
|
containerRef={containerRef}
|
|
178
178
|
className={clsx(className, {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { ClientView } from "akanjs/fetch";
|
|
3
|
+
|
|
4
|
+
import { Load } from "../Load";
|
|
5
|
+
|
|
6
|
+
interface LoadViewProps<T extends string, Model extends { id: string }> {
|
|
7
|
+
view: ClientView<T, Model>;
|
|
8
|
+
}
|
|
9
|
+
export default function LoadView<T extends string, Light extends { id: string }>({ view }: LoadViewProps<T, Light>) {
|
|
10
|
+
return <Load.View view={view} renderView={() => null} loading={null} />;
|
|
11
|
+
}
|
package/ui/Model/index.ts
CHANGED
package/ui/Model/index_.tsx
CHANGED
|
@@ -9,6 +9,7 @@ export const NewWrapper = lazy(() => import("./NewWrapper"));
|
|
|
9
9
|
export const EditWrapper = lazy(() => import("./EditWrapper"));
|
|
10
10
|
export const RemoveWrapper = lazy(() => import("./RemoveWrapper"));
|
|
11
11
|
export const LoadInit = lazy(() => import("./LoadInit"));
|
|
12
|
+
export const LoadView = lazy(() => import("./LoadView"));
|
|
12
13
|
export const ViewWrapper = lazy(() => import("./ViewWrapper"));
|
|
13
14
|
export const ViewEditModal = lazy(() => import("./ViewEditModal"));
|
|
14
15
|
export const Edit = lazy(() => import("./Edit"));
|
package/webkit/index.ts
CHANGED
|
@@ -3,9 +3,13 @@ export { createRobotPage } from "./createRobotPage";
|
|
|
3
3
|
export { createSitemapPage } from "./createSitemapPage";
|
|
4
4
|
export { lazy } from "./lazy";
|
|
5
5
|
export type * from "./types";
|
|
6
|
+
export { useCamera } from "./useCamera";
|
|
7
|
+
export { useCodepush } from "./useCodepush";
|
|
8
|
+
export { useContact } from "./useContact";
|
|
6
9
|
export { useCsrValues } from "./useCsrValues";
|
|
7
10
|
export { useDebounce } from "./useDebounce";
|
|
8
11
|
export { useFetch, useFetchFn } from "./useFetch";
|
|
12
|
+
export { useGeoLocation } from "./useGeoLocation";
|
|
9
13
|
export { useHistory } from "./useHistory";
|
|
10
14
|
export { useInterval } from "./useInterval";
|
|
11
15
|
export { useLocation } from "./useLocation";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Device, isMobileDevice } from "akanjs/client";
|
|
3
|
+
import { type CapacitorPermissionState, loadCapacitorCamera } from "akanjs/client/capacitor";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
type PermissionStatus = {
|
|
7
|
+
camera: CapacitorPermissionState;
|
|
8
|
+
photos: CapacitorPermissionState;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Capacitor camera/photos hook with permission checks and app-settings fallback. */
|
|
12
|
+
export const useCamera = () => {
|
|
13
|
+
const [permissions, setPermissions] = useState<PermissionStatus>({ camera: "prompt", photos: "prompt" });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 최초로 킬 경우 권한은 prompt 상태이다.
|
|
17
|
+
* prompt 상태일 경우 권한을 요청한다.
|
|
18
|
+
* 권한이 denied 상태일 경우 설정으로 이동한다.
|
|
19
|
+
* 이후 state의 permission을 업데이트해야한다.
|
|
20
|
+
*
|
|
21
|
+
*/
|
|
22
|
+
const checkPermission = async (type: "photos" | "camera" | "all") => {
|
|
23
|
+
try {
|
|
24
|
+
const { Camera } = await loadCapacitorCamera();
|
|
25
|
+
if (type === "photos") {
|
|
26
|
+
if (permissions.photos === "prompt") {
|
|
27
|
+
const { photos } = await Camera.requestPermissions();
|
|
28
|
+
setPermissions((prev) => ({ ...prev, photos }));
|
|
29
|
+
} else if (permissions.photos === "denied") {
|
|
30
|
+
location.assign("app-settings:");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
} else if (type === "camera") {
|
|
34
|
+
if (permissions.camera === "prompt") {
|
|
35
|
+
const { camera } = await Camera.requestPermissions();
|
|
36
|
+
setPermissions((prev) => ({ ...prev, camera }));
|
|
37
|
+
} else if (permissions.camera === "denied") {
|
|
38
|
+
location.assign("app-settings:");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
if (permissions.camera === "prompt" || permissions.photos === "prompt") {
|
|
43
|
+
const permissions = await Camera.requestPermissions();
|
|
44
|
+
setPermissions(permissions);
|
|
45
|
+
} else if (permissions.camera === "denied" || permissions.photos === "denied") {
|
|
46
|
+
location.assign("app-settings:");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const getPhoto = async (src: "prompt" | "camera" | "photos" = "prompt") => {
|
|
55
|
+
const { Camera, CameraResultType, CameraSource } = await loadCapacitorCamera();
|
|
56
|
+
const source =
|
|
57
|
+
Device.getDevice().info.platform !== "web"
|
|
58
|
+
? src === "prompt"
|
|
59
|
+
? CameraSource.Prompt
|
|
60
|
+
: src === "camera"
|
|
61
|
+
? CameraSource.Camera
|
|
62
|
+
: CameraSource.Photos
|
|
63
|
+
: CameraSource.Photos;
|
|
64
|
+
const permission = src === "prompt" ? "all" : src === "camera" ? "camera" : "photos";
|
|
65
|
+
await checkPermission(permission);
|
|
66
|
+
try {
|
|
67
|
+
const photo = await Camera.getPhoto({
|
|
68
|
+
quality: 100,
|
|
69
|
+
source,
|
|
70
|
+
allowEditing: false,
|
|
71
|
+
resultType: CameraResultType.DataUrl,
|
|
72
|
+
promptLabelHeader: "프로필 사진을 올려주세요",
|
|
73
|
+
promptLabelPhoto: "앨범에서 선택하기",
|
|
74
|
+
promptLabelPicture: "사진 찍기",
|
|
75
|
+
promptLabelCancel: "취소",
|
|
76
|
+
});
|
|
77
|
+
return photo;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (e === "User cancelled photos app") return;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const pickImage = async () => {
|
|
84
|
+
await checkPermission("photos");
|
|
85
|
+
const { Camera } = await loadCapacitorCamera();
|
|
86
|
+
const photo = await Camera.pickImages({
|
|
87
|
+
quality: 90,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return photo;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
void (async () => {
|
|
95
|
+
if (isMobileDevice()) {
|
|
96
|
+
const { Camera } = await loadCapacitorCamera();
|
|
97
|
+
const permissions = await Camera.checkPermissions();
|
|
98
|
+
setPermissions(permissions);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
}, []);
|
|
102
|
+
return { permissions, getPhoto, pickImage, checkPermission };
|
|
103
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { loadCapacitorApp, loadCapacitorDevice, loadCapacitorUpdater } from "akanjs/client/capacitor";
|
|
3
|
+
import { HttpClient, mergeVersion, splitVersion } from "akanjs/common";
|
|
4
|
+
import type { ProtoAppInfo, ProtoFile } from "akanjs/constant";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
|
|
7
|
+
export const useCodepush = ({ serverUrl }: { serverUrl: string }) => {
|
|
8
|
+
const [update, setUpdate] = useState(false);
|
|
9
|
+
const [version, setVersion] = useState("");
|
|
10
|
+
|
|
11
|
+
const initialize = async () => {
|
|
12
|
+
const { CapacitorUpdater } = await loadCapacitorUpdater();
|
|
13
|
+
await CapacitorUpdater.notifyAppReady();
|
|
14
|
+
};
|
|
15
|
+
const checkNewRelease = async () => {
|
|
16
|
+
const [{ App }, { Device }, { CapacitorUpdater }] = await Promise.all([
|
|
17
|
+
loadCapacitorApp(),
|
|
18
|
+
loadCapacitorDevice(),
|
|
19
|
+
loadCapacitorUpdater(),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const info = await Device.getInfo();
|
|
23
|
+
const app = await App.getInfo();
|
|
24
|
+
const pluginVersion = await CapacitorUpdater.getPluginVersion();
|
|
25
|
+
const { deviceId } = await CapacitorUpdater.getDeviceId();
|
|
26
|
+
const { bundle: version, native } = await CapacitorUpdater.current();
|
|
27
|
+
const builtInversion = await CapacitorUpdater.getBuiltinVersion();
|
|
28
|
+
const appId = app.id;
|
|
29
|
+
const platform = info.platform;
|
|
30
|
+
|
|
31
|
+
window.alert(
|
|
32
|
+
`getBuildinVersion:${builtInversion.version}\ncurrent.bundle:${version.version}\ncurrennt.native:${native}`,
|
|
33
|
+
);
|
|
34
|
+
/**
|
|
35
|
+
* "version_name": "builtin",
|
|
36
|
+
* "version_code": "1",
|
|
37
|
+
* "app_id": "com.lu.app",
|
|
38
|
+
* "plugin_version": "5.6.9",
|
|
39
|
+
* "version_build": "1.0",
|
|
40
|
+
* "is_prod": true,
|
|
41
|
+
* "version_os": "17.0.1",
|
|
42
|
+
* "is_emulator": true,
|
|
43
|
+
* "custom_id": "",
|
|
44
|
+
* "device_id": "C77000B1-7D28-4697-ADE0-74452F47C350",
|
|
45
|
+
* "platform": "ios",
|
|
46
|
+
* "defaultChannel": ""
|
|
47
|
+
*/
|
|
48
|
+
const { major, minor, patch } = splitVersion(version.version === "builtin" ? app.version : version.version);
|
|
49
|
+
const appName = process.env.AKAN_PUBLIC_APP_NAME ?? "";
|
|
50
|
+
|
|
51
|
+
const appInfo: ProtoAppInfo = {
|
|
52
|
+
appId,
|
|
53
|
+
appName,
|
|
54
|
+
deviceId: deviceId,
|
|
55
|
+
platform: platform as "ios" | "android",
|
|
56
|
+
branch: process.env.AKAN_PUBLIC_ENV ?? "debug",
|
|
57
|
+
isEmulator: info.isVirtual,
|
|
58
|
+
major: parseInt(major),
|
|
59
|
+
minor: parseInt(minor),
|
|
60
|
+
patch: parseInt(patch),
|
|
61
|
+
buildNum: app.build, //앱내 빌드시 버전 횟수 모르면 고한테 물어보기
|
|
62
|
+
versionOs: info.osVersion,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const url = serverUrl.replace("lu", "akasys");
|
|
66
|
+
const httpClient = new HttpClient(url);
|
|
67
|
+
const release = await httpClient.post<(ProtoAppInfo & { appBuild: string }) | null>("/release/codepush", {
|
|
68
|
+
data: { ...appInfo },
|
|
69
|
+
});
|
|
70
|
+
if (!release) return;
|
|
71
|
+
const file = await httpClient.get<ProtoFile>(`/file/file/${release.appBuild}`);
|
|
72
|
+
|
|
73
|
+
return { release: release, bundleFile: file };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const codepush = async () => {
|
|
77
|
+
|
|
78
|
+
const newRelease = await checkNewRelease();
|
|
79
|
+
if (!newRelease) return;
|
|
80
|
+
const { release, bundleFile } = newRelease;
|
|
81
|
+
const { CapacitorUpdater } = await loadCapacitorUpdater();
|
|
82
|
+
setUpdate(true);
|
|
83
|
+
const bundle = await CapacitorUpdater.download({
|
|
84
|
+
url: bundleFile.url,
|
|
85
|
+
version: mergeVersion(release.major, release.minor, release.patch),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await CapacitorUpdater.set(bundle);
|
|
89
|
+
};
|
|
90
|
+
const getVersion = async () => {
|
|
91
|
+
const { CapacitorUpdater } = await loadCapacitorUpdater();
|
|
92
|
+
return await CapacitorUpdater.getBuiltinVersion();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const statManager = async () => {
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return { update, version, initialize, checkNewRelease, codepush, statManager };
|
|
99
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Device } from "akanjs/client";
|
|
3
|
+
import { type CapacitorPermissionState, loadCapacitorContacts } from "akanjs/client/capacitor";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
type PermissionStatus = {
|
|
7
|
+
contacts: CapacitorPermissionState;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Capacitor contacts hook with permission checks and contact loading helpers. */
|
|
11
|
+
export const useContact = () => {
|
|
12
|
+
const [permissions, setPermissions] = useState<PermissionStatus>({ contacts: "prompt" });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 최초로 킬 경우 권한은 prompt 상태이다.
|
|
16
|
+
* prompt 상태일 경우 권한을 요청한다.
|
|
17
|
+
* 권한이 denied 상태일 경우 설정으로 이동한다.
|
|
18
|
+
* 이후 state의 permission을 업데이트해야한다.
|
|
19
|
+
*
|
|
20
|
+
*/
|
|
21
|
+
const checkPermission = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const { Contacts } = await loadCapacitorContacts();
|
|
24
|
+
if (permissions.contacts === "prompt") {
|
|
25
|
+
const { contacts } = await Contacts.requestPermissions();
|
|
26
|
+
setPermissions((prev) => ({ ...prev, contacts }));
|
|
27
|
+
} else if (permissions.contacts === "denied") {
|
|
28
|
+
location.assign("app-settings:");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const getContacts = async () => {
|
|
36
|
+
await checkPermission();
|
|
37
|
+
const { Contacts } = await loadCapacitorContacts();
|
|
38
|
+
const { contacts } = await Contacts.getContacts({ projection: { name: true, phones: true } });
|
|
39
|
+
return contacts;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
void (async () => {
|
|
44
|
+
if (Device.getDevice().info.platform === "web") return;
|
|
45
|
+
const { Contacts } = await loadCapacitorContacts();
|
|
46
|
+
const permissions = await Contacts.checkPermissions();
|
|
47
|
+
setPermissions(permissions);
|
|
48
|
+
})();
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
return { permissions, getContacts, checkPermission };
|
|
52
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { loadCapacitorGeolocation } from "akanjs/client/capacitor";
|
|
3
|
+
|
|
4
|
+
/** Capacitor geolocation hook with permission checks and current position lookup. */
|
|
5
|
+
export const useGeoLocation = () => {
|
|
6
|
+
const checkPermission = async (): Promise<{ geolocation: string; coarseLocation: string }> => {
|
|
7
|
+
const { Geolocation } = await loadCapacitorGeolocation();
|
|
8
|
+
const { location: geolocation, coarseLocation } = await Geolocation.requestPermissions();
|
|
9
|
+
return { geolocation, coarseLocation };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getPosition = async () => {
|
|
13
|
+
const { geolocation, coarseLocation } = await checkPermission();
|
|
14
|
+
if (geolocation === "denied" || coarseLocation === "denied") {
|
|
15
|
+
location.assign("app-settings:");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { Geolocation } = await loadCapacitorGeolocation();
|
|
19
|
+
const coordinates = await Geolocation.getCurrentPosition();
|
|
20
|
+
return coordinates;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return { checkPermission, getPosition };
|
|
24
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import "cordova-plugin-purchase/www/store";
|
|
4
|
+
|
|
5
|
+
import { loadCapacitorApp } from "akanjs/client/capacitor";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
|
|
8
|
+
export type PlatformType = "android" | "ios" | "all";
|
|
9
|
+
export interface ProductType {
|
|
10
|
+
id: string;
|
|
11
|
+
type: keyof typeof CdvPurchase.ProductType;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type CdvProductType = CdvPurchase.ProductType;
|
|
15
|
+
|
|
16
|
+
export const usePurchase = ({
|
|
17
|
+
platform,
|
|
18
|
+
productInfo,
|
|
19
|
+
url,
|
|
20
|
+
onPay,
|
|
21
|
+
onSubscribe,
|
|
22
|
+
}: {
|
|
23
|
+
platform: PlatformType;
|
|
24
|
+
productInfo: ProductType[];
|
|
25
|
+
url: string;
|
|
26
|
+
onPay?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
|
|
27
|
+
onSubscribe?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
|
|
28
|
+
}) => {
|
|
29
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
+
const billingRef = useRef<any>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const init = async () => {
|
|
34
|
+
if (CdvPurchase.store.isReady) {
|
|
35
|
+
setIsLoading(false);
|
|
36
|
+
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { App } = await loadCapacitorApp();
|
|
40
|
+
const app = await App.getInfo();
|
|
41
|
+
if (platform === "all")
|
|
42
|
+
CdvPurchase.store.register([
|
|
43
|
+
...productInfo.map((prouct) => ({
|
|
44
|
+
id: prouct.id,
|
|
45
|
+
platform: CdvPurchase.Platform.GOOGLE_PLAY,
|
|
46
|
+
type: CdvPurchase.ProductType[prouct.type],
|
|
47
|
+
})),
|
|
48
|
+
...productInfo.map((prouct) => ({
|
|
49
|
+
id: prouct.id,
|
|
50
|
+
platform: CdvPurchase.Platform.APPLE_APPSTORE,
|
|
51
|
+
type: CdvPurchase.ProductType[prouct.type],
|
|
52
|
+
})),
|
|
53
|
+
]);
|
|
54
|
+
else
|
|
55
|
+
CdvPurchase.store.register(
|
|
56
|
+
productInfo.map((product) => ({
|
|
57
|
+
id: product.id,
|
|
58
|
+
platform: platform === "android" ? CdvPurchase.Platform.GOOGLE_PLAY : CdvPurchase.Platform.APPLE_APPSTORE,
|
|
59
|
+
type: CdvPurchase.ProductType[product.type],
|
|
60
|
+
})),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await CdvPurchase.store.initialize([
|
|
64
|
+
{ platform: CdvPurchase.Platform.APPLE_APPSTORE, options: { needAppReceipt: false } },
|
|
65
|
+
{ platform: CdvPurchase.Platform.GOOGLE_PLAY },
|
|
66
|
+
]);
|
|
67
|
+
await CdvPurchase.store.update();
|
|
68
|
+
await CdvPurchase.store.restorePurchases();
|
|
69
|
+
|
|
70
|
+
CdvPurchase.store.validator = (async (
|
|
71
|
+
request: { id: string; transaction: { id: string; purchaseToken: string; appStoreReceipt: string } },
|
|
72
|
+
callback: (result: {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
data: { id: string; latest_receipt: boolean; transaction: CdvPurchase.Transaction };
|
|
75
|
+
}) => void,
|
|
76
|
+
) => {
|
|
77
|
+
const transactionId = request.transaction.id;
|
|
78
|
+
const transactions = CdvPurchase.store.localTransactions;
|
|
79
|
+
const verifingTransaction = transactions.find((transaction) => transaction.transactionId === transactionId);
|
|
80
|
+
|
|
81
|
+
if (verifingTransaction?.state !== "approved") return;
|
|
82
|
+
|
|
83
|
+
const billing = await fetch(`${url}/billing/verifyBilling`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
data: {
|
|
90
|
+
platform: verifingTransaction.platform === CdvPurchase.Platform.GOOGLE_PLAY ? "google" : "apple",
|
|
91
|
+
packageName: app.id,
|
|
92
|
+
productId: verifingTransaction.products[0].id,
|
|
93
|
+
receipt:
|
|
94
|
+
verifingTransaction.platform === CdvPurchase.Platform.GOOGLE_PLAY
|
|
95
|
+
? request.transaction.purchaseToken
|
|
96
|
+
: request.transaction.appStoreReceipt,
|
|
97
|
+
transactionId: verifingTransaction.transactionId,
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
billingRef.current = billing.json();
|
|
103
|
+
|
|
104
|
+
callback({
|
|
105
|
+
|
|
106
|
+
ok: !!billing,
|
|
107
|
+
|
|
108
|
+
data: { id: request.id, latest_receipt: true, transaction: request.transaction } as any,
|
|
109
|
+
});
|
|
110
|
+
}) as any;
|
|
111
|
+
if (CdvPurchase.store.localReceipts.length > 0) {
|
|
112
|
+
CdvPurchase.store.localReceipts.forEach((receipt) => {
|
|
113
|
+
if (receipt.platform === CdvPurchase.Platform.GOOGLE_PLAY)
|
|
114
|
+
if (receipt.transactions[0].state === CdvPurchase.TransactionState.APPROVED)
|
|
115
|
+
void receipt.transactions[0].verify();
|
|
116
|
+
else void receipt.transactions[0].finish();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
CdvPurchase.store
|
|
121
|
+
.when()
|
|
122
|
+
.approved((transaction) => {
|
|
123
|
+
void transaction.verify();
|
|
124
|
+
})
|
|
125
|
+
.verified((receipt) => {
|
|
126
|
+
void receipt.finish();
|
|
127
|
+
})
|
|
128
|
+
.finished((transaction) => {
|
|
129
|
+
void inAppPurchase(transaction);
|
|
130
|
+
});
|
|
131
|
+
setIsLoading(false);
|
|
132
|
+
};
|
|
133
|
+
void init();
|
|
134
|
+
}, []);
|
|
135
|
+
const purchaseProduct = async (product: CdvPurchase.Product) => {
|
|
136
|
+
await product.getOffer()?.order();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const restorePurchases = async () => {
|
|
140
|
+
await CdvPurchase.store.restorePurchases();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const inAppPurchase = async (transaction: CdvPurchase.Transaction) => {
|
|
144
|
+
const product = CdvPurchase.store.get(transaction.products[0].id);
|
|
145
|
+
if (product?.type === "consumable") await onPay?.(transaction);
|
|
146
|
+
else await onSubscribe?.(transaction);
|
|
147
|
+
await transaction.finish();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
isLoading,
|
|
152
|
+
products: CdvPurchase.store.products,
|
|
153
|
+
purchaseProduct,
|
|
154
|
+
restorePurchases,
|
|
155
|
+
};
|
|
156
|
+
};
|