akanjs 2.4.1 → 2.4.2-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 +7 -0
- package/client/frameConfig.ts +6 -1
- package/client/rscNavigation.ts +9 -0
- package/common/index.ts +5 -0
- package/common/websocketAuth.ts +24 -0
- package/fetch/client/fetchClient.ts +1 -0
- package/fetch/client/wsClient.ts +28 -1
- package/index.ts +6 -0
- package/package.json +1 -1
- package/server/akanServer.ts +5 -4
- package/server/resolver/signal.resolver.ts +23 -0
- package/server/routing/apiRouter.ts +11 -3
- package/server/routing/appWsData.ts +50 -0
- package/server/rscClient.tsx +9 -0
- package/signal/signalContext.ts +37 -12
- package/signal/types.ts +2 -1
- package/types/client/csrTypes.d.ts +7 -0
- package/types/client/rscNavigation.d.ts +8 -0
- package/types/common/index.d.ts +1 -0
- package/types/common/websocketAuth.d.ts +19 -0
- package/types/fetch/client/wsClient.d.ts +6 -0
- package/types/index.d.ts +6 -0
- package/types/server/resolver/signal.resolver.d.ts +6 -0
- package/types/server/routing/apiRouter.d.ts +3 -4
- package/types/server/routing/appWsData.d.ts +24 -0
- package/types/server/rscClient.d.ts +1 -0
- package/types/signal/signalContext.d.ts +7 -0
- package/types/signal/types.d.ts +5 -1
- package/ui/Model/EditModal.tsx +24 -6
package/client/csrTypes.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface PageConfig {
|
|
|
50
50
|
rscPatchHeadSafe?: boolean;
|
|
51
51
|
topSafeAreaColor?: string;
|
|
52
52
|
bottomSafeAreaColor?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
|
|
55
|
+
* reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
|
|
56
|
+
* directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
|
|
57
|
+
* the source without evaluating the module.
|
|
58
|
+
*/
|
|
59
|
+
devOnly?: boolean;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface CsrState {
|
package/client/frameConfig.ts
CHANGED
|
@@ -24,6 +24,8 @@ const pageConfigKeys = new Set<keyof PageConfig>([
|
|
|
24
24
|
"topSafeAreaColor",
|
|
25
25
|
"bottomSafeAreaColor",
|
|
26
26
|
]);
|
|
27
|
+
|
|
28
|
+
const buildPageConfigKeys = new Set<keyof PageConfig>(["devOnly"]);
|
|
27
29
|
const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
|
|
28
30
|
const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
|
|
29
31
|
const DEFAULT_BOOLEAN_INSET = 48;
|
|
@@ -38,10 +40,13 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
|
|
|
38
40
|
if (!isRecord(config)) throw new Error(`[route-convention] pageConfig in ${routeKey} must be an object.`);
|
|
39
41
|
const pageConfig = config as PageConfig;
|
|
40
42
|
for (const key of Object.keys(pageConfig)) {
|
|
41
|
-
if (!pageConfigKeys.has(key as keyof PageConfig)) {
|
|
43
|
+
if (!pageConfigKeys.has(key as keyof PageConfig) && !buildPageConfigKeys.has(key as keyof PageConfig)) {
|
|
42
44
|
throw new Error(`[route-convention] unsupported pageConfig option "${key}" in ${routeKey}`);
|
|
43
45
|
}
|
|
44
46
|
}
|
|
47
|
+
if (pageConfig.devOnly !== undefined && typeof pageConfig.devOnly !== "boolean") {
|
|
48
|
+
throw new Error(`[route-convention] pageConfig.devOnly in ${routeKey} must be a boolean.`);
|
|
49
|
+
}
|
|
45
50
|
if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
|
|
46
51
|
throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
|
|
47
52
|
}
|
package/client/rscNavigation.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
declare global {
|
|
2
2
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
3
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
3
4
|
var __AKAN_RSC_NAVIGATE__:
|
|
4
5
|
| ((href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => Promise<void>)
|
|
5
6
|
| undefined;
|
|
@@ -9,11 +10,19 @@ export const clearRscNavigationCache = () => {
|
|
|
9
10
|
globalThis.__AKAN_RSC_CLEAR_CACHE__?.();
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* True when the page tree currently on screen was replayed from the RSC navigation cache instead of
|
|
15
|
+
* fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
|
|
16
|
+
* that must show current values should refetch.
|
|
17
|
+
*/
|
|
18
|
+
export const isRscNavigationFromCache = () => globalThis.__AKAN_RSC_IS_FROM_CACHE__?.() ?? false;
|
|
19
|
+
|
|
12
20
|
export const navigateRsc = (href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => {
|
|
13
21
|
return globalThis.__AKAN_RSC_NAVIGATE__?.(href, options);
|
|
14
22
|
};
|
|
15
23
|
|
|
16
24
|
export const useRscNavigation = () => ({
|
|
17
25
|
clearCache: clearRscNavigationCache,
|
|
26
|
+
isFromCache: isRscNavigationFromCache,
|
|
18
27
|
navigate: navigateRsc,
|
|
19
28
|
});
|
package/common/index.ts
CHANGED
|
@@ -54,3 +54,8 @@ export { sleep } from "./sleep";
|
|
|
54
54
|
export { splitVersion } from "./splitVersion";
|
|
55
55
|
export { getBasePathFromPathname, parseBasePaths } from "./subRoute";
|
|
56
56
|
export type * from "./types";
|
|
57
|
+
export {
|
|
58
|
+
type WebsocketAuthAckData,
|
|
59
|
+
type WebsocketAuthRequest,
|
|
60
|
+
websocketAuthContract,
|
|
61
|
+
} from "./websocketAuth";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface WebsocketAuthRequest {
|
|
2
|
+
key: string;
|
|
3
|
+
data: [string | null];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface WebsocketAuthAckData {
|
|
7
|
+
type: "auth";
|
|
8
|
+
revokedRooms: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Framework-owned websocket auth contract shared by the client and the server dispatcher.
|
|
13
|
+
* The credential frame carries the raw bearer token; verifying it stays in userland middleware,
|
|
14
|
+
* so the server only swaps the credential snapshot held on the socket.
|
|
15
|
+
*/
|
|
16
|
+
export const websocketAuthContract = {
|
|
17
|
+
key: "__auth",
|
|
18
|
+
makeRequest: (jwt: string | null): WebsocketAuthRequest => ({ key: "__auth", data: [jwt] }),
|
|
19
|
+
makeAck: (revokedRooms: string[]): WebsocketAuthAckData => ({ type: "auth", revokedRooms }),
|
|
20
|
+
readJwt: (data: unknown): string | null => {
|
|
21
|
+
const jwt = Array.isArray(data) ? data[0] : null;
|
|
22
|
+
return typeof jwt === "string" && jwt.length > 0 ? jwt : null;
|
|
23
|
+
},
|
|
24
|
+
} as const;
|
|
@@ -197,6 +197,7 @@ export class FetchClient {
|
|
|
197
197
|
}
|
|
198
198
|
setJwt(jwt: string | null) {
|
|
199
199
|
this.jwt = jwt;
|
|
200
|
+
this.ws.setJwt(jwt);
|
|
200
201
|
}
|
|
201
202
|
#makeAuthHeaders(option?: FetchPolicy): Record<string, string> {
|
|
202
203
|
if (option?.token) return { Authorization: `Bearer ${option.token}` };
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Logger } from "akanjs/common";
|
|
1
|
+
import { Logger, websocketAuthContract } from "akanjs/common";
|
|
2
2
|
import type {
|
|
3
|
+
WebsocketAuthAck,
|
|
3
4
|
WebsocketMessageData,
|
|
4
5
|
WebsocketPublishData,
|
|
5
6
|
WebsocketReqData,
|
|
@@ -39,6 +40,7 @@ export class WsClient {
|
|
|
39
40
|
#roomSubscribeMap = new Map<string, SubscribeOption>();
|
|
40
41
|
#listenerMap = new Map<string, Set<Listener>>();
|
|
41
42
|
#destroyed = false;
|
|
43
|
+
#jwt: string | null = null;
|
|
42
44
|
connected = false;
|
|
43
45
|
|
|
44
46
|
constructor(
|
|
@@ -52,6 +54,21 @@ export class WsClient {
|
|
|
52
54
|
this.ErrorCls = ErrorCls;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The handshake only carries a same-origin cookie, so clients that hold the token in memory
|
|
59
|
+
* (native, cross-origin) authenticate with this frame instead. Signing out sends `null`, which
|
|
60
|
+
* drops the handshake cookie server-side and revokes the rooms it had authorized.
|
|
61
|
+
*/
|
|
62
|
+
setJwt(jwt: string | null) {
|
|
63
|
+
if (this.#jwt === jwt) return;
|
|
64
|
+
this.#jwt = jwt;
|
|
65
|
+
if (this.#ws?.readyState === WebSocket.OPEN) this.#sendAuth();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#sendAuth() {
|
|
69
|
+
this.#ws?.send(JSON.stringify(websocketAuthContract.makeRequest(this.#jwt)));
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
connect() {
|
|
56
73
|
if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) return;
|
|
57
74
|
this.logger.debug(`Connecting to ${this.url}`);
|
|
@@ -68,6 +85,8 @@ export class WsClient {
|
|
|
68
85
|
this.#reconnectAttempts = 0;
|
|
69
86
|
this.connected = true;
|
|
70
87
|
this.logger.debug(`WebSocket connected`);
|
|
88
|
+
|
|
89
|
+
if (this.#jwt) this.#sendAuth();
|
|
71
90
|
this.#roomSubscribeMap.forEach((option) => {
|
|
72
91
|
const data: WebsocketReqData = { key: option.key, data: option.data, subscribe: true };
|
|
73
92
|
this.#ws?.send(JSON.stringify(data));
|
|
@@ -97,6 +116,14 @@ export class WsClient {
|
|
|
97
116
|
this.#handlePubsub(publishData.roomId, publishData.data);
|
|
98
117
|
break;
|
|
99
118
|
}
|
|
119
|
+
case "auth": {
|
|
120
|
+
const ack = parsed as WebsocketAuthAck;
|
|
121
|
+
for (const roomId of ack.revokedRooms) {
|
|
122
|
+
this.#roomSubscribeMap.delete(roomId);
|
|
123
|
+
this.logger.warn(`Websocket room ${roomId} is no longer authorized`);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
100
127
|
default:
|
|
101
128
|
this.logger.warn(`Unknown WebSocket message type: ${type} ${JSON.stringify(parsed)}`);
|
|
102
129
|
break;
|
package/index.ts
CHANGED
|
@@ -163,6 +163,12 @@ export interface AppConfigResult {
|
|
|
163
163
|
docker: DockerConfig;
|
|
164
164
|
defaultDatabaseMode: DatabaseMode;
|
|
165
165
|
routes?: AkanRouteConfig[];
|
|
166
|
+
/**
|
|
167
|
+
* Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
|
|
168
|
+
* dependency that ships a `page` folder, an array takes exactly the libs listed, `false` (the default)
|
|
169
|
+
* syncs nothing and removes what a previous sync created.
|
|
170
|
+
*/
|
|
171
|
+
syncPageLibs?: string[] | boolean;
|
|
166
172
|
externalLibs: string[];
|
|
167
173
|
barrelImports: string[];
|
|
168
174
|
optimizeImports: string[];
|
package/package.json
CHANGED
package/server/akanServer.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
|
23
23
|
import { WebProxyRunner } from "./proxy";
|
|
24
24
|
import { SignalResolver } from "./resolver";
|
|
25
25
|
import { ApiRouter } from "./routing/apiRouter";
|
|
26
|
+
import type { AppWsData } from "./routing/appWsData";
|
|
26
27
|
import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "./types";
|
|
27
28
|
import type { WebRouter } from "./webRouter";
|
|
28
29
|
|
|
@@ -65,8 +66,8 @@ export interface AkanServerConsoleInfo {
|
|
|
65
66
|
export class AkanServer {
|
|
66
67
|
status: "stopped" | "initializing" | "initialized" | "starting" | "running" | "stopping" = "stopped";
|
|
67
68
|
|
|
68
|
-
#server: Bun.Server<
|
|
69
|
-
#wsServer: Bun.Server<
|
|
69
|
+
#server: Bun.Server<AppWsData | HmrWsData> | null = null;
|
|
70
|
+
#wsServer: Bun.Server<AppWsData | HmrWsData> | null = null;
|
|
70
71
|
#prepared: AkanAppPrepared | null = null;
|
|
71
72
|
readonly logger: Logger;
|
|
72
73
|
readonly name: string;
|
|
@@ -241,7 +242,7 @@ export class AkanServer {
|
|
|
241
242
|
}),
|
|
242
243
|
|
|
243
244
|
data: {},
|
|
244
|
-
} as Bun.WebSocketHandler<
|
|
245
|
+
} as Bun.WebSocketHandler<AppWsData | HmrWsData>;
|
|
245
246
|
|
|
246
247
|
this.#server = Bun.serve({
|
|
247
248
|
idleTimeout: 0,
|
|
@@ -270,7 +271,7 @@ export class AkanServer {
|
|
|
270
271
|
builtinRoutes,
|
|
271
272
|
routeOptions,
|
|
272
273
|
renderEnvRoutes,
|
|
273
|
-
upgradeAppWs: (req: Request, data:
|
|
274
|
+
upgradeAppWs: (req: Request, data: AppWsData) => this.#wsServer?.upgrade(req, { data }) ?? false,
|
|
274
275
|
webProxyRunner,
|
|
275
276
|
}),
|
|
276
277
|
websocket: websocketHandlers,
|
|
@@ -452,6 +452,29 @@ export class SignalResolver {
|
|
|
452
452
|
return Boolean(req.headers.get("authorization") || req.headers.get("cookie")?.includes("jwt="));
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Re-checks the guards of every room this socket is subscribed to and drops the ones that no
|
|
457
|
+
* longer pass. Called when the socket's credential changes: a pubsub room is authorized once at
|
|
458
|
+
* subscribe time, so without this a signed-out socket would keep receiving its old rooms.
|
|
459
|
+
*/
|
|
460
|
+
static async revalidateWsRooms(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<string[]> {
|
|
461
|
+
const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
|
|
462
|
+
if (!roomCtxMap?.size) return [];
|
|
463
|
+
const websocket = SignalResolver.#getWebsocket(registry);
|
|
464
|
+
const revokedRooms: string[] = [];
|
|
465
|
+
for (const [roomId, roomCtx] of [...roomCtxMap]) {
|
|
466
|
+
if (await roomCtx.authorize()) continue;
|
|
467
|
+
ws.unsubscribe(roomId);
|
|
468
|
+
await Promise.all([...roomCtx.getWebSocketContext().onUnsubscribe.values()].map((handler) => handler()));
|
|
469
|
+
roomCtxMap.delete(roomId);
|
|
470
|
+
websocket.leaveRoom(ws, roomId);
|
|
471
|
+
revokedRooms.push(roomId);
|
|
472
|
+
SignalResolver.logger.verbose(`WebSocket lost access to room ${roomId}; unsubscribed`);
|
|
473
|
+
}
|
|
474
|
+
if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
|
|
475
|
+
return revokedRooms;
|
|
476
|
+
}
|
|
477
|
+
|
|
455
478
|
static async handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
|
|
456
479
|
await SignalResolver.#getWebsocket(registry).registerSocket(ws);
|
|
457
480
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { dayjs } from "akanjs/base";
|
|
2
|
-
import type
|
|
2
|
+
import { type Logger, websocketAuthContract } from "akanjs/common";
|
|
3
3
|
import type { InjectRegistry } from "akanjs/service";
|
|
4
4
|
import { Exception, type WebsocketReqData } from "akanjs/signal";
|
|
5
5
|
import type { HmrWsData, HmrWsHub } from "../hmr/wsHub";
|
|
6
6
|
import { copyBunRequestFields, type WebProxyRunner } from "../proxy";
|
|
7
7
|
import { SignalResolver } from "../resolver";
|
|
8
8
|
import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types";
|
|
9
|
+
import { AppWsData } from "./appWsData";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Minimal render-state view the HMR WS hello message needs.
|
|
@@ -30,7 +31,7 @@ export interface ApiRouteInputs {
|
|
|
30
31
|
routeOptions?: Record<string, SignalRouteOptions>;
|
|
31
32
|
renderEnvRoutes: HttpRoutes;
|
|
32
33
|
/** Upgrades the incoming request into an app-signal WebSocket. */
|
|
33
|
-
upgradeAppWs: (req: Request, data:
|
|
34
|
+
upgradeAppWs: (req: Request, data: AppWsData) => boolean;
|
|
34
35
|
webProxyRunner?: WebProxyRunner | null;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -85,7 +86,7 @@ export class ApiRouter {
|
|
|
85
86
|
const endpointPaths = new Set([...endpointEntries.map(([path]) => path), ...builtinEntries.map(([path]) => path)]);
|
|
86
87
|
const routeTable = {
|
|
87
88
|
[`${prefix}${websocketPrefix}` as "/api/ws"]: (req) => {
|
|
88
|
-
const upgraded = upgradeAppWs(req,
|
|
89
|
+
const upgraded = upgradeAppWs(req, AppWsData.fromRequest(req));
|
|
89
90
|
if (upgraded) return;
|
|
90
91
|
return new Response("Failed to upgrade to WebSocket", { status: 500 });
|
|
91
92
|
},
|
|
@@ -138,6 +139,13 @@ export class ApiRouter {
|
|
|
138
139
|
if (typeof message === "string") {
|
|
139
140
|
const msg = JSON.parse(message) as WebsocketReqData;
|
|
140
141
|
if (!msg.key) throw new Error("Message key is required");
|
|
142
|
+
if (msg.key === websocketAuthContract.key) {
|
|
143
|
+
|
|
144
|
+
AppWsData.applyCredential(AppWsData.of(ws), websocketAuthContract.readJwt(msg.data));
|
|
145
|
+
const revokedRooms = await SignalResolver.revalidateWsRooms(ws, registry);
|
|
146
|
+
ws.send(JSON.stringify(websocketAuthContract.makeAck(revokedRooms)));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
141
149
|
const wsRoute = wsRoutes[msg.key];
|
|
142
150
|
if (!wsRoute) throw new Error(`WebSocket route "${msg.key}" is not registered`);
|
|
143
151
|
const eventType =
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Credential snapshot taken at the websocket handshake and carried on `ws.data` for the life of the
|
|
5
|
+
* socket, so auth middleware and guards can read the caller the same way they read an HTTP request.
|
|
6
|
+
* Only the credential headers are copied — retaining the whole `Request` would pin it for as long
|
|
7
|
+
* as the socket stays open.
|
|
8
|
+
*/
|
|
9
|
+
export class AppWsData {
|
|
10
|
+
static fromRequest(req: Request): AppWsData {
|
|
11
|
+
const headers = new Headers();
|
|
12
|
+
for (const key of CREDENTIAL_HEADERS) {
|
|
13
|
+
const value = req.headers.get(key);
|
|
14
|
+
if (value) headers.set(key, value);
|
|
15
|
+
}
|
|
16
|
+
return new AppWsData(headers);
|
|
17
|
+
}
|
|
18
|
+
static of(ws: Bun.ServerWebSocket<unknown>): AppWsData {
|
|
19
|
+
return ws.data as AppWsData;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Swaps the credential the socket authenticates with. Callers must run this synchronously on the
|
|
23
|
+
* auth frame: frames arrive in order, so a subscribe sent right after the credential must not be
|
|
24
|
+
* able to observe the previous one.
|
|
25
|
+
*/
|
|
26
|
+
static applyCredential(data: AppWsData, jwt: string | null) {
|
|
27
|
+
if (jwt) data.headers.set("authorization", `Bearer ${jwt}`);
|
|
28
|
+
else {
|
|
29
|
+
data.headers.delete("authorization");
|
|
30
|
+
data.cookies.delete("jwt");
|
|
31
|
+
const cookie = [...data.cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
32
|
+
if (cookie) data.headers.set("cookie", cookie);
|
|
33
|
+
else data.headers.delete("cookie");
|
|
34
|
+
}
|
|
35
|
+
data.account = undefined;
|
|
36
|
+
data.resolvedAuthorization = undefined;
|
|
37
|
+
}
|
|
38
|
+
createdAt: number;
|
|
39
|
+
headers: Headers;
|
|
40
|
+
cookies: Bun.CookieMap;
|
|
41
|
+
account?: unknown;
|
|
42
|
+
/** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
|
|
43
|
+
resolvedAuthorization?: string;
|
|
44
|
+
socketId?: string;
|
|
45
|
+
constructor(headers: Headers) {
|
|
46
|
+
this.createdAt = Date.now();
|
|
47
|
+
this.headers = headers;
|
|
48
|
+
this.cookies = new Bun.CookieMap(headers.get("cookie") ?? "");
|
|
49
|
+
}
|
|
50
|
+
}
|
package/server/rscClient.tsx
CHANGED
|
@@ -47,6 +47,7 @@ declare global {
|
|
|
47
47
|
| undefined;
|
|
48
48
|
var __AKAN_RSC_REFRESH__: ((options?: { buildId?: number }) => Promise<void>) | undefined;
|
|
49
49
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
50
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
50
51
|
var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
|
|
51
52
|
var __AKAN_DEV_SYNC_NAVIGATION_APPLYING__: boolean | undefined;
|
|
52
53
|
var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
|
|
@@ -299,8 +300,11 @@ let currentRouterState: AkanRouterStateV1 | null = initialRouterState;
|
|
|
299
300
|
let currentSegmentTree: RscSegmentCacheNode | null = createAkanSegmentCacheTree(initialNode);
|
|
300
301
|
let currentFullNode: RscCacheNode = initialNode;
|
|
301
302
|
let currentCommitKind: "full" | "patch" = "full";
|
|
303
|
+
let currentCommitFromCache = false;
|
|
302
304
|
let navigationSeq = 0;
|
|
303
305
|
|
|
306
|
+
globalThis.__AKAN_RSC_IS_FROM_CACHE__ = () => currentCommitFromCache;
|
|
307
|
+
|
|
304
308
|
function rememberCommittedRouteState(node: RscCacheNode): void {
|
|
305
309
|
rscPatchCache.clear();
|
|
306
310
|
if (!node.routerState) return;
|
|
@@ -378,6 +382,7 @@ function Root(): ReactNode {
|
|
|
378
382
|
maxEntries: MAX_RSC_CACHE_ENTRIES,
|
|
379
383
|
startTransition,
|
|
380
384
|
commitThenable: (node) => {
|
|
385
|
+
currentCommitFromCache = false;
|
|
381
386
|
resetAkanSegmentOutletPatches();
|
|
382
387
|
setThenable(node.thenable);
|
|
383
388
|
},
|
|
@@ -397,6 +402,7 @@ function Root(): ReactNode {
|
|
|
397
402
|
const scrollToTop = options.scrollToTop ?? true;
|
|
398
403
|
try {
|
|
399
404
|
let nextNode = rscCache.get(target);
|
|
405
|
+
const servedFromCache = !!nextNode;
|
|
400
406
|
if (!nextNode) {
|
|
401
407
|
const cachedPatch = rscPatchCache.get(target);
|
|
402
408
|
if (cachedPatch) {
|
|
@@ -425,6 +431,7 @@ function Root(): ReactNode {
|
|
|
425
431
|
bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
|
|
426
432
|
})
|
|
427
433
|
) {
|
|
434
|
+
currentCommitFromCache = true;
|
|
428
435
|
rememberPatchedRouteState(patchResult.tree, patchResult.patchedNode);
|
|
429
436
|
rememberRscPatchCacheNode(rscPatchCache, cachedPatch, MAX_RSC_CACHE_ENTRIES);
|
|
430
437
|
return;
|
|
@@ -449,6 +456,7 @@ function Root(): ReactNode {
|
|
|
449
456
|
bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
|
|
450
457
|
})
|
|
451
458
|
) {
|
|
459
|
+
currentCommitFromCache = false;
|
|
452
460
|
rememberPatchedRouteState(fetched.tree, fetched.patchedNode);
|
|
453
461
|
const patchCacheNode = createRscPatchNavigationCacheNode({
|
|
454
462
|
href: target,
|
|
@@ -493,6 +501,7 @@ function Root(): ReactNode {
|
|
|
493
501
|
maxEntries: MAX_RSC_CACHE_ENTRIES,
|
|
494
502
|
startTransition,
|
|
495
503
|
commitThenable: (node) => {
|
|
504
|
+
currentCommitFromCache = servedFromCache;
|
|
496
505
|
resetAkanSegmentOutletPatches();
|
|
497
506
|
setThenable(node.thenable);
|
|
498
507
|
},
|
package/signal/signalContext.ts
CHANGED
|
@@ -125,6 +125,38 @@ export class SignalContext<
|
|
|
125
125
|
}),
|
|
126
126
|
);
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Re-checks this context's guards outside of a request, for a websocket room that is already
|
|
130
|
+
* subscribed. Only global middlewares run: they carry the account resolution this depends on,
|
|
131
|
+
* while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
|
|
132
|
+
*/
|
|
133
|
+
async authorize(): Promise<boolean> {
|
|
134
|
+
try {
|
|
135
|
+
await this.#withMiddleware(async () => await this.#checkGuards(), { endpointMiddlewares: false })();
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
#withMiddleware(
|
|
142
|
+
coreExec: () => Promise<unknown>,
|
|
143
|
+
{ endpointMiddlewares = true }: { endpointMiddlewares?: boolean } = {},
|
|
144
|
+
): () => Promise<unknown> {
|
|
145
|
+
const middlewares = [
|
|
146
|
+
...this.#middleware.values(),
|
|
147
|
+
...(endpointMiddlewares ? (this.endpointInfo.signalOption.middlewares ?? []) : []),
|
|
148
|
+
];
|
|
149
|
+
if (middlewares.length === 0) return coreExec;
|
|
150
|
+
let next = coreExec;
|
|
151
|
+
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
152
|
+
const MiddlewareCls = middlewares[i];
|
|
153
|
+
if (!MiddlewareCls) continue;
|
|
154
|
+
const middleware = new MiddlewareCls();
|
|
155
|
+
const currentNext = next;
|
|
156
|
+
next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
128
160
|
async exec() {
|
|
129
161
|
if (!this.trace) return await this.#exec();
|
|
130
162
|
return await runWithTrace(this.trace, async () => {
|
|
@@ -137,7 +169,6 @@ export class SignalContext<
|
|
|
137
169
|
}
|
|
138
170
|
async #exec() {
|
|
139
171
|
if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
|
|
140
|
-
const endpointMiddlewares = this.endpointInfo.signalOption.middlewares ?? [];
|
|
141
172
|
const coreExec = async () => {
|
|
142
173
|
if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
|
|
143
174
|
if (this.trace) await traceSpan("guards", () => this.#checkGuards());
|
|
@@ -158,17 +189,7 @@ export class SignalContext<
|
|
|
158
189
|
async () => await this.endpointInfo.execFn?.call(this.adaptor, ...this.args, ...this.internalArgs),
|
|
159
190
|
);
|
|
160
191
|
};
|
|
161
|
-
|
|
162
|
-
if (this.#middleware.size > 0 || endpointMiddlewares.length > 0) {
|
|
163
|
-
const middlewares = [...this.#middleware.values(), ...endpointMiddlewares];
|
|
164
|
-
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
165
|
-
const MiddlewareCls = middlewares[i];
|
|
166
|
-
if (!MiddlewareCls) continue;
|
|
167
|
-
const middleware = new MiddlewareCls();
|
|
168
|
-
const currentNext = next;
|
|
169
|
-
next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
192
|
+
const next = this.#withMiddleware(coreExec);
|
|
172
193
|
const result = this.trace ? await traceSpan("execChain", () => next()) : await next();
|
|
173
194
|
if (this.endpointInfo.type === "pubsub") return;
|
|
174
195
|
if (result instanceof Response) return result;
|
|
@@ -340,6 +361,10 @@ export class SignalContext<
|
|
|
340
361
|
if (this.transport !== "websocket") throw new Error("Transport is not websocket");
|
|
341
362
|
return this.ctx as WebSocketExecutionContext<Appended>;
|
|
342
363
|
}
|
|
364
|
+
get<T = unknown>(key: string): T | null {
|
|
365
|
+
if (this.transport === "http") return this.getHttpContext<{ [key: string]: T }>().req[key] ?? null;
|
|
366
|
+
return this.getWebSocketContext<{ [key: string]: T }>().ws.data[key] ?? null;
|
|
367
|
+
}
|
|
343
368
|
getRoomId(key: string) {
|
|
344
369
|
if (this.transport !== "websocket") throw new Error("Transport is not websocket");
|
|
345
370
|
else if (this.endpointInfo.type !== "pubsub") throw new Error("Endpoint is not pubsub");
|
package/signal/types.ts
CHANGED
|
@@ -140,4 +140,5 @@ export type WebsocketReqData = { key: string; data: unknown[]; subscribe?: boole
|
|
|
140
140
|
export type WebsocketMessageData = { type: "msg"; key: string; data: object | object[] };
|
|
141
141
|
export type WebsocketSubscribeAck = { type: "sub"; roomId: string; subscribe: boolean };
|
|
142
142
|
export type WebsocketPublishData = { type: "pub"; roomId: string; data: object | object[] };
|
|
143
|
-
export type
|
|
143
|
+
export type WebsocketAuthAck = { type: "auth"; revokedRooms: string[] };
|
|
144
|
+
export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
|
|
@@ -43,6 +43,13 @@ export interface PageConfig {
|
|
|
43
43
|
rscPatchHeadSafe?: boolean;
|
|
44
44
|
topSafeAreaColor?: string;
|
|
45
45
|
bottomSafeAreaColor?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
|
|
48
|
+
* reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
|
|
49
|
+
* directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
|
|
50
|
+
* the source without evaluating the module.
|
|
51
|
+
*/
|
|
52
|
+
devOnly?: boolean;
|
|
46
53
|
}
|
|
47
54
|
export interface CsrState {
|
|
48
55
|
transition: TransitionType;
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
declare global {
|
|
2
2
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
3
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
3
4
|
var __AKAN_RSC_NAVIGATE__: ((href: string, options?: {
|
|
4
5
|
replace?: boolean;
|
|
5
6
|
scrollToTop?: boolean;
|
|
6
7
|
}) => Promise<void>) | undefined;
|
|
7
8
|
}
|
|
8
9
|
export declare const clearRscNavigationCache: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* True when the page tree currently on screen was replayed from the RSC navigation cache instead of
|
|
12
|
+
* fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
|
|
13
|
+
* that must show current values should refetch.
|
|
14
|
+
*/
|
|
15
|
+
export declare const isRscNavigationFromCache: () => boolean;
|
|
9
16
|
export declare const navigateRsc: (href: string, options?: {
|
|
10
17
|
replace?: boolean;
|
|
11
18
|
scrollToTop?: boolean;
|
|
12
19
|
}) => Promise<void> | undefined;
|
|
13
20
|
export declare const useRscNavigation: () => {
|
|
14
21
|
clearCache: () => void;
|
|
22
|
+
isFromCache: () => boolean;
|
|
15
23
|
navigate: (href: string, options?: {
|
|
16
24
|
replace?: boolean;
|
|
17
25
|
scrollToTop?: boolean;
|
package/types/common/index.d.ts
CHANGED
|
@@ -27,3 +27,4 @@ export { sleep } from "./sleep.d.ts";
|
|
|
27
27
|
export { splitVersion } from "./splitVersion.d.ts";
|
|
28
28
|
export { getBasePathFromPathname, parseBasePaths } from "./subRoute.d.ts";
|
|
29
29
|
export type * from "./types.d.ts";
|
|
30
|
+
export { type WebsocketAuthAckData, type WebsocketAuthRequest, websocketAuthContract, } from "./websocketAuth.d.ts";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface WebsocketAuthRequest {
|
|
2
|
+
key: string;
|
|
3
|
+
data: [string | null];
|
|
4
|
+
}
|
|
5
|
+
export interface WebsocketAuthAckData {
|
|
6
|
+
type: "auth";
|
|
7
|
+
revokedRooms: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Framework-owned websocket auth contract shared by the client and the server dispatcher.
|
|
11
|
+
* The credential frame carries the raw bearer token; verifying it stays in userland middleware,
|
|
12
|
+
* so the server only swaps the credential snapshot held on the socket.
|
|
13
|
+
*/
|
|
14
|
+
export declare const websocketAuthContract: {
|
|
15
|
+
readonly key: "__auth";
|
|
16
|
+
readonly makeRequest: (jwt: string | null) => WebsocketAuthRequest;
|
|
17
|
+
readonly makeAck: (revokedRooms: string[]) => WebsocketAuthAckData;
|
|
18
|
+
readonly readJwt: (data: unknown) => string | null;
|
|
19
|
+
};
|
|
@@ -15,6 +15,12 @@ export declare class WsClient {
|
|
|
15
15
|
connected: boolean;
|
|
16
16
|
constructor(url: string, ErrorCls?: ErrorConstructor | undefined);
|
|
17
17
|
setErrorConstructor(ErrorCls?: ErrorConstructor): void;
|
|
18
|
+
/**
|
|
19
|
+
* The handshake only carries a same-origin cookie, so clients that hold the token in memory
|
|
20
|
+
* (native, cross-origin) authenticate with this frame instead. Signing out sends `null`, which
|
|
21
|
+
* drops the handshake cookie server-side and revokes the rooms it had authorized.
|
|
22
|
+
*/
|
|
23
|
+
setJwt(jwt: string | null): void;
|
|
18
24
|
connect(): void;
|
|
19
25
|
destroy(): void;
|
|
20
26
|
on<Data = unknown>(key: string, callback: (data: Data) => void): this;
|
package/types/index.d.ts
CHANGED
|
@@ -161,6 +161,12 @@ export interface AppConfigResult {
|
|
|
161
161
|
docker: DockerConfig;
|
|
162
162
|
defaultDatabaseMode: DatabaseMode;
|
|
163
163
|
routes?: AkanRouteConfig[];
|
|
164
|
+
/**
|
|
165
|
+
* Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
|
|
166
|
+
* dependency that ships a `page` folder, an array takes exactly the libs listed, `false` (the default)
|
|
167
|
+
* syncs nothing and removes what a previous sync created.
|
|
168
|
+
*/
|
|
169
|
+
syncPageLibs?: string[] | boolean;
|
|
164
170
|
externalLibs: string[];
|
|
165
171
|
barrelImports: string[];
|
|
166
172
|
optimizeImports: string[];
|
|
@@ -30,6 +30,12 @@ export declare class SignalResolver {
|
|
|
30
30
|
live: LiveRegistry;
|
|
31
31
|
middleware: Map<string, MiddlewareCls>;
|
|
32
32
|
}): SignalRoutes;
|
|
33
|
+
/**
|
|
34
|
+
* Re-checks the guards of every room this socket is subscribed to and drops the ones that no
|
|
35
|
+
* longer pass. Called when the socket's credential changes: a pubsub room is authorized once at
|
|
36
|
+
* subscribe time, so without this a signed-out socket would keep receiving its old rooms.
|
|
37
|
+
*/
|
|
38
|
+
static revalidateWsRooms(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<string[]>;
|
|
33
39
|
static handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<void>;
|
|
34
40
|
static handleWsClose(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<void>;
|
|
35
41
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Logger } from "akanjs/common";
|
|
2
2
|
import type { InjectRegistry } from "akanjs/service";
|
|
3
3
|
import type { HmrWsHub } from "../hmr/wsHub.d.ts";
|
|
4
4
|
import { type WebProxyRunner } from "../proxy.d.ts";
|
|
5
5
|
import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types.d.ts";
|
|
6
|
+
import { AppWsData } from "./appWsData.d.ts";
|
|
6
7
|
/**
|
|
7
8
|
* Minimal render-state view the HMR WS hello message needs.
|
|
8
9
|
* `LazyHmrController` exposes this shape via `state.buildId` / `state.cssAssets`,
|
|
@@ -27,9 +28,7 @@ export interface ApiRouteInputs {
|
|
|
27
28
|
routeOptions?: Record<string, SignalRouteOptions>;
|
|
28
29
|
renderEnvRoutes: HttpRoutes;
|
|
29
30
|
/** Upgrades the incoming request into an app-signal WebSocket. */
|
|
30
|
-
upgradeAppWs: (req: Request, data:
|
|
31
|
-
createdAt: number;
|
|
32
|
-
}) => boolean;
|
|
31
|
+
upgradeAppWs: (req: Request, data: AppWsData) => boolean;
|
|
33
32
|
webProxyRunner?: WebProxyRunner | null;
|
|
34
33
|
}
|
|
35
34
|
export interface WebsocketHandlersInputs {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential snapshot taken at the websocket handshake and carried on `ws.data` for the life of the
|
|
3
|
+
* socket, so auth middleware and guards can read the caller the same way they read an HTTP request.
|
|
4
|
+
* Only the credential headers are copied — retaining the whole `Request` would pin it for as long
|
|
5
|
+
* as the socket stays open.
|
|
6
|
+
*/
|
|
7
|
+
export declare class AppWsData {
|
|
8
|
+
static fromRequest(req: Request): AppWsData;
|
|
9
|
+
static of(ws: Bun.ServerWebSocket<unknown>): AppWsData;
|
|
10
|
+
/**
|
|
11
|
+
* Swaps the credential the socket authenticates with. Callers must run this synchronously on the
|
|
12
|
+
* auth frame: frames arrive in order, so a subscribe sent right after the credential must not be
|
|
13
|
+
* able to observe the previous one.
|
|
14
|
+
*/
|
|
15
|
+
static applyCredential(data: AppWsData, jwt: string | null): void;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
headers: Headers;
|
|
18
|
+
cookies: Bun.CookieMap;
|
|
19
|
+
account?: unknown;
|
|
20
|
+
/** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
|
|
21
|
+
resolvedAuthorization?: string;
|
|
22
|
+
socketId?: string;
|
|
23
|
+
constructor(headers: Headers);
|
|
24
|
+
}
|
|
@@ -13,6 +13,7 @@ declare global {
|
|
|
13
13
|
buildId?: number;
|
|
14
14
|
}) => Promise<void>) | undefined;
|
|
15
15
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
16
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
16
17
|
var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
|
|
17
18
|
var __AKAN_DEV_SYNC_NAVIGATION_APPLYING__: boolean | undefined;
|
|
18
19
|
var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
|
|
@@ -32,6 +32,12 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
|
|
|
32
32
|
getAdaptor<T extends Adaptor>(adaptorCls: AdaptorCls<T>): T;
|
|
33
33
|
getService<T>(refName: string): T;
|
|
34
34
|
init(): Promise<this>;
|
|
35
|
+
/**
|
|
36
|
+
* Re-checks this context's guards outside of a request, for a websocket room that is already
|
|
37
|
+
* subscribed. Only global middlewares run: they carry the account resolution this depends on,
|
|
38
|
+
* while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
|
|
39
|
+
*/
|
|
40
|
+
authorize(): Promise<boolean>;
|
|
35
41
|
exec(): Promise<Response | undefined>;
|
|
36
42
|
static try(endpoint: Adaptor, endpointInfo: EndpointInfo, key: string, fn: () => Promise<Response | undefined>): Promise<Response | undefined>;
|
|
37
43
|
static resolveReturn(value: unknown, { signalContext, returnRef, arrDepth, registry, live, }: {
|
|
@@ -47,6 +53,7 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
|
|
|
47
53
|
}): Promise<unknown>;
|
|
48
54
|
getHttpContext<Appended = unknown>(): HttpExecutionContext<Appended>;
|
|
49
55
|
getWebSocketContext<Appended = unknown>(): WebSocketExecutionContext<Appended>;
|
|
56
|
+
get<T = unknown>(key: string): T | null;
|
|
50
57
|
getRoomId(key: string): string;
|
|
51
58
|
getEnv(): Env;
|
|
52
59
|
getArg<T = unknown>(argName: string): T | undefined;
|
package/types/signal/types.d.ts
CHANGED
|
@@ -147,5 +147,9 @@ export type WebsocketPublishData = {
|
|
|
147
147
|
roomId: string;
|
|
148
148
|
data: object | object[];
|
|
149
149
|
};
|
|
150
|
-
export type
|
|
150
|
+
export type WebsocketAuthAck = {
|
|
151
|
+
type: "auth";
|
|
152
|
+
revokedRooms: string[];
|
|
153
|
+
};
|
|
154
|
+
export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
|
|
151
155
|
export {};
|
package/ui/Model/EditModal.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { clsx, router, usePage } from "akanjs/client";
|
|
2
|
+
import { clsx, isRscNavigationFromCache, router, usePage } from "akanjs/client";
|
|
3
3
|
import { capitalize, deepObjectify, lowerlize } from "akanjs/common";
|
|
4
4
|
import { ConstantRegistry, immerify } from "akanjs/constant";
|
|
5
5
|
import type { ClientEdit, ServerEdit, SliceMeta } from "akanjs/fetch";
|
|
@@ -11,6 +11,8 @@ import { AiOutlinePlus, AiOutlineSave } from "react-icons/ai";
|
|
|
11
11
|
import { Button } from "../Button";
|
|
12
12
|
import { Modal } from "../Modal";
|
|
13
13
|
|
|
14
|
+
const EDIT_PAYLOAD_MAX_AGE_MS = 60_000;
|
|
15
|
+
|
|
14
16
|
interface EditModelProps<Full> {
|
|
15
17
|
/** Rendering mode for the edit shell. */
|
|
16
18
|
type?: "modal" | "form" | "empty";
|
|
@@ -148,6 +150,7 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
148
150
|
setModelModal: `set${ModelName}Modal`,
|
|
149
151
|
modelLoading: `${modelName}Loading`,
|
|
150
152
|
modelViewAt: `${modelName}ViewAt`,
|
|
153
|
+
editModel: `edit${ModelName}`,
|
|
151
154
|
newModel: `new${ModelName}`,
|
|
152
155
|
crystalizeModel: `crystalize${ModelName}`,
|
|
153
156
|
modelObj: `${modelName}Obj`,
|
|
@@ -159,10 +162,19 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
159
162
|
(state: unknown) => (state as { [key: string]: { id: string | null } })[names.modelForm].id,
|
|
160
163
|
);
|
|
161
164
|
const modelFormLoading = storeUse[names.modelFormLoading]() as string | boolean;
|
|
165
|
+
const modalId = id ?? ((modelEdit as any)?.[names.modelObj] as Full | undefined)?.id ?? undefined;
|
|
162
166
|
const isModalOpen =
|
|
163
167
|
modelModal === (modal ?? "edit") &&
|
|
164
|
-
(modelFormLoading === false || modelFormLoading ===
|
|
165
|
-
((!modelFormId && !
|
|
168
|
+
(modelFormLoading === false || modelFormLoading === modalId) &&
|
|
169
|
+
((!modelFormId && !modalId) || modalId === modelFormId);
|
|
170
|
+
const isEditPayloadStale = useCallback((viewAt?: Date | null) => {
|
|
171
|
+
if (isRscNavigationFromCache()) return true;
|
|
172
|
+
return (
|
|
173
|
+
viewAt instanceof Date &&
|
|
174
|
+
!Number.isNaN(viewAt.getTime()) &&
|
|
175
|
+
Date.now() - viewAt.getTime() > EDIT_PAYLOAD_MAX_AGE_MS
|
|
176
|
+
);
|
|
177
|
+
}, []);
|
|
166
178
|
useEffect(() => {
|
|
167
179
|
if (!modelEdit) return;
|
|
168
180
|
const refName = (modelEdit as ServerEdit<string, Full>).refName;
|
|
@@ -170,15 +182,21 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
170
182
|
const cnst = ConstantRegistry.getDatabase(modelName);
|
|
171
183
|
const modelRef = cnst.full;
|
|
172
184
|
if (editType === "edit") {
|
|
173
|
-
const
|
|
185
|
+
const modelObj = (modelEdit as any)[names.modelObj] as Full;
|
|
186
|
+
const viewAt = (modelEdit as any)[names.modelViewAt] as Date;
|
|
187
|
+
const crystal = new modelRef().set(modelObj) as unknown as Full;
|
|
174
188
|
st.set({
|
|
175
189
|
[names.model]: crystal,
|
|
176
190
|
[names.modelLoading]: false,
|
|
177
191
|
[names.modelForm]: immerify(modelRef, crystal),
|
|
178
192
|
[names.modelFormLoading]: false,
|
|
179
193
|
[names.modelModal]: modal ?? "edit",
|
|
180
|
-
[names.modelViewAt]:
|
|
194
|
+
[names.modelViewAt]: viewAt,
|
|
181
195
|
});
|
|
196
|
+
if (isEditPayloadStale(viewAt))
|
|
197
|
+
void storeDo[names.editModel](modelObj.id, { modal }).catch(() => {
|
|
198
|
+
st.set({ [names.modelFormLoading]: false });
|
|
199
|
+
});
|
|
182
200
|
} else {
|
|
183
201
|
|
|
184
202
|
const crystal = new modelRef().set(modelEdit as Full) as unknown as Full;
|
|
@@ -186,7 +204,7 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
186
204
|
}
|
|
187
205
|
return () => {
|
|
188
206
|
};
|
|
189
|
-
}, [modelEdit]);
|
|
207
|
+
}, [modelEdit, isEditPayloadStale]);
|
|
190
208
|
|
|
191
209
|
const handleCancel = useCallback(() => {
|
|
192
210
|
const modelForm = (st.get() as any)[names.modelForm] as Full;
|