akanjs 2.2.8 → 2.2.9

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.
@@ -96,6 +96,9 @@ export const msg = new Proxy({} as ClientRuntime["msg"], {
96
96
  get(_target, prop, receiver) {
97
97
  return Reflect.get(getClientRuntime().msg, prop, receiver);
98
98
  },
99
+ set(_target, prop, value) {
100
+ return Reflect.set(getClientRuntime().msg, prop, value);
101
+ },
99
102
  });
100
103
 
101
104
  const ErrTarget = function AkanClientRuntimeErr(...args: ConstructorParameters<RuntimeErr>) {
@@ -119,6 +119,7 @@ export interface LayoutModule {
119
119
  manifest?: WebAppManifest;
120
120
  theme?: string;
121
121
  reconnect?: boolean;
122
+ wsConnect?: boolean;
122
123
  layoutStyle?: "mobile" | "web";
123
124
  gaTrackingId?: string;
124
125
  Loading?: LayoutLoadingRender;
@@ -44,6 +44,12 @@ export class Translator {
44
44
  static getActiveLocale(): string | undefined {
45
45
  return getTranslatorState().activeLocale;
46
46
  }
47
+ static translateByLocale(lang: string, key: string, param?: Record<string, string | number>): string {
48
+ const dictionary = getTranslatorState().langDictionaryMap.get(lang);
49
+ if (!dictionary) return key;
50
+ const msg = (pathGet(key, dictionary, ".", { t: key }) as { t: string }).t;
51
+ return param ? msg.replace(/{([^}]+)}/g, (_, key: string) => param[key] as string) : msg;
52
+ }
47
53
 
48
54
  static seed(lang: string, dict: Dictionary | undefined) {
49
55
  if (!dict) return;
@@ -58,10 +64,7 @@ export class Translator {
58
64
  state.langDictionaryMap.set(lang, existingDictionary);
59
65
  }
60
66
  translate(lang: string, key: string, param?: Record<string, string | number>): string {
61
- const dictionary = getTranslatorState().langDictionaryMap.get(lang);
62
- if (!dictionary) return key;
63
- const msg = (pathGet(key, dictionary, ".", { t: key }) as { t: string }).t;
64
- return param ? msg.replace(/{([^}]+)}/g, (_, key: string) => param[key] as string) : msg;
67
+ return Translator.translateByLocale(lang, key, param);
65
68
  }
66
69
  async getDictionary(lang: string) {
67
70
  const dictionary = getTranslatorState().langDictionaryMap.get(lang);
@@ -20,7 +20,6 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
20
20
  .arg((t) => ({
21
21
  id: t(["ID", "아이디"]),
22
22
  })),
23
- cleanup: fn(["Cleanup", "Cleanup"]).desc(["Cleanup operation", "정리 작업"]),
24
23
  wsPing: fn(["Socket.io Ping", "Socket.io Ping"])
25
24
  .desc(["Socket.io Ping test", "Socket.io Ping 테스트"])
26
25
  .arg((t) => ({
@@ -173,6 +173,10 @@ export class FetchClient {
173
173
  if (!handler) throw new Error(`${owner} requires fetch handler "${key}", but it is not registered`);
174
174
  return handler as T;
175
175
  }
176
+ #setHandlerFactory(key: string, factory: FetchHandlerFactory) {
177
+ this.#handlerFactory.set(key, factory);
178
+ delete this.#handlerStore[key];
179
+ }
176
180
  connect() {
177
181
  this.ws.connect();
178
182
  }
@@ -256,15 +260,15 @@ export class FetchClient {
256
260
  #registerEndpoint(key: string, endpoint: SerializedEndpoint, prefix?: string) {
257
261
  switch (endpoint.type) {
258
262
  case "query": {
259
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, endpoint, prefix));
263
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, endpoint, prefix));
260
264
  return;
261
265
  }
262
266
  case "mutation": {
263
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, endpoint, prefix));
267
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, endpoint, prefix));
264
268
  return;
265
269
  }
266
270
  case "pubsub": {
267
- this.#handlerFactory.set(`subscribe${capitalize(key)}`, () => {
271
+ this.#setHandlerFactory(`subscribe${capitalize(key)}`, () => {
268
272
  const roomArgs = endpoint.args.filter((arg) => arg.type === "room");
269
273
  const roomArgLength = roomArgs.length;
270
274
  const serializerMap = this.#makeArgSerializer(endpoint.args);
@@ -292,7 +296,7 @@ export class FetchClient {
292
296
  return;
293
297
  }
294
298
  case "message": {
295
- this.#handlerFactory.set(key, () => {
299
+ this.#setHandlerFactory(key, () => {
296
300
  const msgArgs = endpoint.args.filter((arg) => arg.type === "msg");
297
301
  const msgArgLength = msgArgs.length;
298
302
  const serializerMap = this.#makeArgSerializer(endpoint.args);
@@ -302,7 +306,7 @@ export class FetchClient {
302
306
  this.ws.emit(key, data);
303
307
  };
304
308
  });
305
- this.#handlerFactory.set(`listen${capitalize(key)}`, () => {
309
+ this.#setHandlerFactory(`listen${capitalize(key)}`, () => {
306
310
  const parseReturn = this.#makeReturnParser(endpoint.returns);
307
311
  const wrappedListeners = new WeakMap<(data: unknown) => void, (data: unknown) => void>();
308
312
  return ((handleEvent: (data: unknown) => void, fetchPolicy: FetchPolicy = {}) => {
@@ -407,11 +411,11 @@ export class FetchClient {
407
411
  };
408
412
  const endpoint = FetchClient.getBaseEndpoint(refName, signal);
409
413
  Object.entries(endpoint).forEach(([key, value]) => {
410
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, value, signal.prefix));
414
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, signal.prefix));
411
415
  });
412
416
 
413
417
  if (signal.cruGuards) {
414
- this.#handlerFactory.set(
418
+ this.#setHandlerFactory(
415
419
  names.viewModel,
416
420
  () =>
417
421
  (async (id: string, option?: FetchPolicy) => {
@@ -425,7 +429,7 @@ export class FetchClient {
425
429
  };
426
430
  }) as FetchHandler,
427
431
  );
428
- this.#handlerFactory.set(
432
+ this.#setHandlerFactory(
429
433
  names.getModelView,
430
434
  () =>
431
435
  (async (id: string, option?: FetchPolicy) => {
@@ -434,7 +438,7 @@ export class FetchClient {
434
438
  return { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() };
435
439
  }) as FetchHandler,
436
440
  );
437
- this.#handlerFactory.set(
441
+ this.#setHandlerFactory(
438
442
  names.editModel,
439
443
  () =>
440
444
  (async (id: string, option?: FetchPolicy) => {
@@ -448,7 +452,7 @@ export class FetchClient {
448
452
  };
449
453
  }) as FetchHandler,
450
454
  );
451
- this.#handlerFactory.set(
455
+ this.#setHandlerFactory(
452
456
  names.getModelEdit,
453
457
  () =>
454
458
  (async (id: string, option?: FetchPolicy) => {
@@ -457,7 +461,7 @@ export class FetchClient {
457
461
  return { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() };
458
462
  }) as FetchHandler,
459
463
  );
460
- this.#handlerFactory.set(
464
+ this.#setHandlerFactory(
461
465
  names.mergeModel,
462
466
  () =>
463
467
  (async (modelOrId: string | { id: string }, data: UnknownRecord, option?: FetchPolicy) => {
@@ -468,7 +472,7 @@ export class FetchClient {
468
472
  );
469
473
  }
470
474
 
471
- this.#handlerFactory.set(
475
+ this.#setHandlerFactory(
472
476
  names.addModelFiles,
473
477
  () =>
474
478
  (async (fileList: FileList, parentId?: string, option?: FetchPolicy) => {
@@ -529,12 +533,12 @@ export class FetchClient {
529
533
 
530
534
  const endpoint = FetchClient.getEndpointFromSlice(refName, suffix, slice);
531
535
  Object.entries(endpoint).forEach(([key, value]) => {
532
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, value, prefix));
536
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, prefix));
533
537
  });
534
538
 
535
539
  const argLength = slice.args.length;
536
540
  this.slice[sliceName] = { refName, sliceName, argLength };
537
- this.#handlerFactory.set(names.init, () => async (...argData: unknown[]) => {
541
+ this.#setHandlerFactory(names.init, () => async (...argData: unknown[]) => {
538
542
  const cnst = ConstantRegistry.getDatabase(refName);
539
543
  const queryArgs = normalizeQueryArgs(
540
544
  Array.from({ length: Math.min(argData.length, argLength) }, (_, idx) => argData[idx]),
@@ -576,7 +580,7 @@ export class FetchClient {
576
580
  [`${refName}Insight${capSuffix}`]: modelInsight,
577
581
  };
578
582
  });
579
- this.#handlerFactory.set(names.getInit, () => async (...args: unknown[]) => {
583
+ this.#setHandlerFactory(names.getInit, () => async (...args: unknown[]) => {
580
584
  const initFn = this.#requireHandler<(...args: unknown[]) => Promise<Record<string, unknown>>>(
581
585
  names.init,
582
586
  names.getInit,
@@ -53,6 +53,7 @@ export class WsClient {
53
53
  }
54
54
 
55
55
  connect() {
56
+ if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) return;
56
57
  this.logger.debug(`Connecting to ${this.url}`);
57
58
  this.#destroyed = false;
58
59
  this.#reconnectAttempts = 0;
@@ -201,9 +202,15 @@ export class WsClient {
201
202
  const hasRoom = roomSub ? roomSub.listener.size > 0 : false;
202
203
  return hasGeneric || hasRoom;
203
204
  }
205
+ #warnNotConnected(action: "emit" | "subscribe", key: string) {
206
+ console.warn(
207
+ `[akanjs] WebSocket is not connected. Call fetch.instance.connect() or enable root layout "wsConnect" before ${action} "${key}".`,
208
+ );
209
+ }
204
210
  emit(key: string, data: WsRequestPayload) {
205
211
  if (this.#ws?.readyState !== WebSocket.OPEN) {
206
212
  this.logger.warn("WebSocket not connected");
213
+ this.#warnNotConnected("emit", key);
207
214
  return this;
208
215
  }
209
216
  const payload: WebsocketReqData = { key, data: Array.isArray(data) ? data : [data] };
@@ -212,6 +219,7 @@ export class WsClient {
212
219
  }
213
220
  subscribe(option: { key: string; data: unknown[]; handleEvent: (data: unknown) => void }) {
214
221
  const roomId = WsClient.makeRoomId(option.key, option.data);
222
+ if (!this.#ws) this.#warnNotConnected("subscribe", option.key);
215
223
  if (!this.#roomSubscribeMap.has(roomId)) {
216
224
  this.#roomSubscribeMap.set(roomId, { key: option.key, data: option.data, listener: new Set() });
217
225
  if (this.#ws?.readyState === WebSocket.OPEN) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.2.8",
3
+ "version": "2.2.9",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -362,6 +362,7 @@ export class DevHmrController {
362
362
  `${path.sep}pkgs${path.sep}akanjs${path.sep}server${path.sep}src${path.sep}ssrFromRscRenderer.tsx`,
363
363
  ];
364
364
  if (files.some((file) => runtimeRoots.some((needle) => path.resolve(file).includes(needle)))) return true;
365
+ if (files.some((file) => path.basename(file).endsWith(".signal.ts"))) return true;
365
366
 
366
367
  return (
367
368
  routeIds === undefined &&
@@ -53,6 +53,7 @@ export class RouteTreeBuilder {
53
53
  "manifest",
54
54
  "theme",
55
55
  "reconnect",
56
+ "wsConnect",
56
57
  "layoutStyle",
57
58
  "gaTrackingId",
58
59
  "Loading",
@@ -69,7 +69,6 @@ export class SsrFromRscRenderer {
69
69
 
70
70
  const renderHtml = () =>
71
71
  renderToReadableStream(<Root />, {
72
- bootstrapModules: input.bootstrapModules,
73
72
  bootstrapScriptContent: bootstrap,
74
73
  });
75
74
  const htmlStream =
@@ -77,6 +76,7 @@ export class SsrFromRscRenderer {
77
76
 
78
77
  const withHeadScripts = SsrFromRscRenderer.#injectHeadScriptsIntoHead(htmlStream, {
79
78
  importmap: input.importmap,
79
+ bootstrapModules: input.bootstrapModules,
80
80
  theme: input.theme,
81
81
  injectThemeInitScript: input.injectThemeInitScript,
82
82
  });
@@ -84,6 +84,7 @@ export class SsrFromRscRenderer {
84
84
  return SsrFromRscRenderer.#appendRscScriptsAfterHtml(
85
85
  withHeadScripts,
86
86
  SsrFromRscRenderer.#sanitizeFlightForClient(rscForClient),
87
+ input.bootstrapModules,
87
88
  input.request,
88
89
  );
89
90
  }
@@ -124,32 +125,35 @@ export class SsrFromRscRenderer {
124
125
  * tag in the outgoing HTML stream.
125
126
  *
126
127
  * We do this as a stream transform (rather than as a React child inside
127
- * `<head>`) because React 19's Float pipeline hoists
128
- * `<link rel="modulepreload">` for every entry in `bootstrapModules` to the
129
- * top of `<head>`. An importmap rendered via JSX ends up *after* those
130
- * hoisted preloads, which per the HTML spec — is too late: once the
131
- * browser starts a module script fetch for a preload, the document's
132
- * "allow-import-maps" bit flips to false and no further importmap can be
133
- * acquired. By writing the importmap before any of React's own `<head>`
134
- * output reaches the wire, we guarantee the browser sees it first.
128
+ * `<head>`) so importmaps are acquired before any modulepreload can start.
129
+ * The spec is strict: once the browser starts a module script fetch for a
130
+ * preload, the document's "allow-import-maps" bit flips to false and no
131
+ * further importmap can be acquired.
135
132
  *
136
133
  * The transform operates on UTF-8 bytes until it has spliced the tag, then
137
134
  * becomes a pure passthrough to avoid any further per-chunk overhead.
138
135
  */
139
136
  static #injectHeadScriptsIntoHead(
140
137
  stream: ReadableStream<Uint8Array>,
141
- options: { importmap?: Record<string, string>; theme?: AkanTheme; injectThemeInitScript?: boolean },
138
+ options: {
139
+ importmap?: Record<string, string>;
140
+ bootstrapModules?: string[];
141
+ theme?: AkanTheme;
142
+ injectThemeInitScript?: boolean;
143
+ },
142
144
  ): ReadableStream<Uint8Array> {
143
145
  const encoder = new TextEncoder();
144
146
  const decoder = new TextDecoder();
145
- const { importmap, theme, injectThemeInitScript } = options;
147
+ const { importmap, bootstrapModules, theme, injectThemeInitScript } = options;
146
148
  const htmlTheme = theme && theme !== "css" && theme !== "system" ? theme : undefined;
147
149
  const importmapTag =
148
150
  importmap && Object.keys(importmap).length > 0
149
151
  ? `<script type="importmap">${JSON.stringify({ imports: importmap })}</script>`
150
152
  : "";
153
+ const modulePreloadTags = SsrFromRscRenderer.#createBootstrapModulePreloadTags(bootstrapModules);
151
154
  const shouldInjectThemeScript = theme === "system" || (theme === undefined && injectThemeInitScript);
152
- const tags = `${shouldInjectThemeScript ? SsrFromRscRenderer.#themeInitScript : ""}${importmapTag}`;
155
+ const themeInitTag = shouldInjectThemeScript ? SsrFromRscRenderer.#themeInitScript : "";
156
+ const tags = `${themeInitTag}${importmapTag}${modulePreloadTags}`;
153
157
  if (!tags && !htmlTheme) return stream;
154
158
  const htmlOpenRe = /<html(\s[^>]*)?>/i;
155
159
  const headOpenRe = /<head(\s[^>]*)?>/i;
@@ -200,6 +204,20 @@ export class SsrFromRscRenderer {
200
204
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
201
205
  }
202
206
 
207
+ static #createBootstrapModulePreloadTags(bootstrapModules?: string[]): string {
208
+ if (!bootstrapModules?.length) return "";
209
+ return bootstrapModules
210
+ .map((src) => `<link rel="modulepreload" href="${SsrFromRscRenderer.#escapeHtmlAttr(src)}">`)
211
+ .join("");
212
+ }
213
+
214
+ static #createBootstrapModuleScriptTags(bootstrapModules?: string[]): string {
215
+ if (!bootstrapModules?.length) return "";
216
+ return bootstrapModules
217
+ .map((src) => `<script type="module" src="${SsrFromRscRenderer.#escapeHtmlAttr(src)}"></script>`)
218
+ .join("");
219
+ }
220
+
203
221
  static #sanitizeFlightForClient(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
204
222
  const decoder = new TextDecoder();
205
223
  const encoder = new TextEncoder();
@@ -227,9 +245,11 @@ export class SsrFromRscRenderer {
227
245
  static #appendRscScriptsAfterHtml(
228
246
  htmlStream: ReadableStream<Uint8Array>,
229
247
  rscClientStream: ReadableStream<Uint8Array>,
248
+ bootstrapModules?: string[],
230
249
  request?: Request,
231
250
  ): ReadableStream<Uint8Array> {
232
251
  const encoder = new TextEncoder();
252
+ const bootstrapModuleScripts = SsrFromRscRenderer.#createBootstrapModuleScriptTags(bootstrapModules);
233
253
 
234
254
  return new ReadableStream<Uint8Array>({
235
255
  start(controller) {
@@ -253,6 +273,8 @@ export class SsrFromRscRenderer {
253
273
  reader.releaseLock();
254
274
  }
255
275
 
276
+ if (bootstrapModuleScripts && !errored) controller.enqueue(encoder.encode(bootstrapModuleScripts));
277
+
256
278
  const rscReader = rscClientStream.getReader();
257
279
  try {
258
280
  while (true) {
@@ -35,12 +35,11 @@ export interface SsrFromRscInput {
35
35
  * guaranteeing one React instance across rscClient and every route
36
36
  * chunk.
37
37
  *
38
- * Injection happens via a stream transform, not React children, because
39
- * React Fizz hoists `<link rel="modulepreload">` (generated from
40
- * `bootstrapModules`) to the top of `<head>`, which would otherwise sit
41
- * before any importmap rendered via JSX. The spec is strict: import maps
42
- * must be acquired before any module script fetch starts, including
43
- * modulepreload.
38
+ * Injection happens via a stream transform, not React children, because the
39
+ * spec is strict: import maps must be acquired before any module script fetch
40
+ * starts, including modulepreload. Akan writes bootstrap module preloads
41
+ * directly after this importmap and delays the executable module script until
42
+ * the Fizz HTML stream has completed.
44
43
  */
45
44
  importmap?: Record<string, string>;
46
45
  theme?: AkanTheme;
@@ -9,10 +9,6 @@ export class BaseService extends serve("base" as const, ({ env, signal, memory }
9
9
  publishPing() {
10
10
  this.baseSignal.pubsubPing("ping");
11
11
  }
12
- async cleanup() {
13
- if (!this.onCleanup) throw new Error("onCleanup is not defined");
14
- await this.onCleanup();
15
- }
16
12
  }
17
13
 
18
14
  export const srv = { base: new ServiceModel(BaseService) };
@@ -18,11 +18,6 @@ export class BaseEndpoint extends endpoint(srv.base, ({ query, mutation, message
18
18
  pingQuery: query(String, { nullable: true })
19
19
  .search("id", String)
20
20
  .exec((id) => `pingQuery: ${id}`),
21
- cleanup: mutation(Boolean).exec(async function () {
22
- if (process.env.NODE_ENV !== "test") throw new Error("cleanup is only available in test environment");
23
- await this.baseService.cleanup();
24
- return true;
25
- }),
26
21
  wsPing: message(String)
27
22
  .msg("data", String, { nullable: true })
28
23
  .exec((data) => `wsPing: ${data}`),
@@ -1,5 +1,6 @@
1
1
  import { ACTION_META, STATE_DERIVED_META, STATE_INIT_META } from "akanjs/base";
2
- import { capitalize, Logger } from "akanjs/common";
2
+ import { Translator } from "akanjs/client/translator";
3
+ import { capitalize, Logger, parseAkanI18nEnv } from "akanjs/common";
3
4
  import { produce } from "immer";
4
5
  import type { RefObject } from "react";
5
6
  import { useEffect, useRef, useSyncExternalStore } from "./hooks";
@@ -9,6 +10,7 @@ import { evaluateInitializers, type SearchParamsState, type StateDerivedMeta } f
9
10
 
10
11
  type StoreStateRecord = Record<string, unknown>;
11
12
  type StoreAction = (...args: unknown[]) => unknown;
13
+ type TranslationParam = Record<string, string | number>;
12
14
 
13
15
  type SliceActionKey =
14
16
  | "initModel"
@@ -20,6 +22,27 @@ type SliceActionKey =
20
22
  | "setQueryArgsOfModel"
21
23
  | "setSortOfModel";
22
24
 
25
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
26
+ Boolean(value && typeof value === "object" && !Array.isArray(value));
27
+
28
+ const getActionErrorKey = (error: unknown) => {
29
+ if (typeof error === "string") return error;
30
+ if (!isRecord(error)) return String(error);
31
+ if (typeof error.error === "string") return error.error;
32
+ if (typeof error.message === "string") return error.message;
33
+ return String(error);
34
+ };
35
+
36
+ const getActionErrorData = (error: unknown): TranslationParam | undefined => {
37
+ if (!isRecord(error) || !isRecord(error.data)) return undefined;
38
+ const data = Object.fromEntries(
39
+ Object.entries(error.data).filter((entry): entry is [string, string | number] =>
40
+ ["string", "number"].includes(typeof entry[1]),
41
+ ),
42
+ );
43
+ return Object.keys(data).length ? data : undefined;
44
+ };
45
+
23
46
  export type ReactAPI = {
24
47
  useSyncExternalStore: <T>(
25
48
  subscribe: (onStoreChange: () => void) => () => void,
@@ -190,12 +213,36 @@ export class StoreInstance {
190
213
  this.do[k] = async (...args: unknown[]) => {
191
214
  Logger.verbose(`${k} action loading...`);
192
215
  const start = Date.now();
193
- await (this.#ctx[k] as StoreAction)(...args);
194
- Logger.verbose(`=> ${k} action dispatched (${Date.now() - start}ms)`);
216
+ try {
217
+ const result = await (this.#ctx[k] as StoreAction)(...args);
218
+ Logger.verbose(`=> ${k} action dispatched (${Date.now() - start}ms)`);
219
+ return result;
220
+ } catch (error) {
221
+ this.#showActionErrorMessage(k, error);
222
+ Logger.error(`${k} action error return: ${error instanceof Error ? error.message : String(error)}`);
223
+ throw error;
224
+ }
195
225
  };
196
226
  }
197
227
  }
198
228
 
229
+ #showActionErrorMessage(actionKey: string, error: unknown) {
230
+ const showMessage = this.#ctx.showMessage;
231
+ if (typeof showMessage !== "function") return;
232
+ try {
233
+ const lang = Translator.getActiveLocale() ?? parseAkanI18nEnv().defaultLocale;
234
+ const errorKey = getActionErrorKey(error);
235
+ const content = Translator.translateByLocale(lang, errorKey, getActionErrorData(error));
236
+ showMessage({ type: "error", key: actionKey, duration: 3, content });
237
+ } catch (messageError) {
238
+ Logger.warn(
239
+ `Failed to show ${actionKey} action error message: ${
240
+ messageError instanceof Error ? messageError.message : String(messageError)
241
+ }`,
242
+ );
243
+ }
244
+ }
245
+
199
246
  #buildSlices(store: RootStoreCls) {
200
247
  Object.entries(store.slice).forEach(([refName, sliceObj]) => {
201
248
  Object.entries(sliceObj).forEach(([suffix, serializedSlice]) => {
@@ -122,6 +122,7 @@ export interface LayoutModule {
122
122
  manifest?: WebAppManifest;
123
123
  theme?: string;
124
124
  reconnect?: boolean;
125
+ wsConnect?: boolean;
125
126
  layoutStyle?: "mobile" | "web";
126
127
  gaTrackingId?: string;
127
128
  Loading?: LayoutLoadingRender;
@@ -11,6 +11,7 @@ export declare class Translator {
11
11
  hasDictionary(lang: string): boolean;
12
12
  static setActiveLocale(lang: string | undefined): void;
13
13
  static getActiveLocale(): string | undefined;
14
+ static translateByLocale(lang: string, key: string, param?: Record<string, string | number>): string;
14
15
  static seed(lang: string, dict: Dictionary | undefined): void;
15
16
  translate(lang: string, key: string, param?: Record<string, string | number>): string;
16
17
  getDictionary(lang: string): Promise<Dictionary>;
@@ -34,12 +34,11 @@ export interface SsrFromRscInput {
34
34
  * guaranteeing one React instance across rscClient and every route
35
35
  * chunk.
36
36
  *
37
- * Injection happens via a stream transform, not React children, because
38
- * React Fizz hoists `<link rel="modulepreload">` (generated from
39
- * `bootstrapModules`) to the top of `<head>`, which would otherwise sit
40
- * before any importmap rendered via JSX. The spec is strict: import maps
41
- * must be acquired before any module script fetch starts, including
42
- * modulepreload.
37
+ * Injection happens via a stream transform, not React children, because the
38
+ * spec is strict: import maps must be acquired before any module script fetch
39
+ * starts, including modulepreload. Akan writes bootstrap module preloads
40
+ * directly after this importmap and delays the executable module script until
41
+ * the Fizz HTML stream has completed.
43
42
  */
44
43
  importmap?: Record<string, string>;
45
44
  theme?: AkanTheme;
@@ -8,7 +8,6 @@ declare const BaseService_base: import("./serve.d.ts").ServiceCls<"base", {}, {
8
8
  }>;
9
9
  export declare class BaseService extends BaseService_base {
10
10
  publishPing(): void;
11
- cleanup(): Promise<void>;
12
11
  }
13
12
  export declare const srv: {
14
13
  base: ServiceModel<typeof BaseService, any, any, {
@@ -18,9 +18,6 @@ declare const BaseEndpoint_base: import("./endpoint.d.ts").EndpointCls<import("a
18
18
  pingQuery: import("./endpointInfo.d.ts").EndpointInfo<"query", {
19
19
  baseService: import("akanjs/service").BaseService;
20
20
  }, [string], [arg?: string | null | undefined], [], [arg: string | undefined], StringConstructor, string, string, true>;
21
- cleanup: import("./endpointInfo.d.ts").EndpointInfo<"mutation", {
22
- baseService: import("akanjs/service").BaseService;
23
- }, [], [], [], [], BooleanConstructor, boolean, Promise<boolean>, false>;
24
21
  wsPing: import("./endpointInfo.d.ts").EndpointInfo<"message", {
25
22
  baseService: import("akanjs/service").BaseService;
26
23
  }, [string], [arg?: string | null | undefined], [], [arg: string | undefined], StringConstructor, string, string, false>;
@@ -5,7 +5,7 @@ export declare const CSR: {
5
5
  ({ children }: {
6
6
  children: ReactNode;
7
7
  }): import("react/jsx-runtime").JSX.Element;
8
- Provider: ({ className, appName, params, head, manifest, env, theme, prefix, children, gaTrackingId, fonts, layoutStyle, reconnect, of, }: CSRProviderProps) => import("react/jsx-runtime").JSX.Element;
8
+ Provider: ({ className, appName, params, head, manifest, env, theme, prefix, children, gaTrackingId, fonts, layoutStyle, reconnect, wsConnect, of, }: CSRProviderProps) => import("react/jsx-runtime").JSX.Element;
9
9
  Wrapper: ({ children, lang, head, manifest, fonts, appName, className, prefix, layoutStyle, }: CSRWrapperProps) => import("react/jsx-runtime").JSX.Element;
10
10
  Inner: () => import("react/jsx-runtime").JSX.Element;
11
11
  Bridge: ({ lang, prefix }: CSRBridgeProps) => null;
@@ -13,7 +13,7 @@ export declare const CSR: {
13
13
  export type CSRProviderProps = ProviderProps & {
14
14
  fonts: ReactFont[];
15
15
  };
16
- declare const CSRProvider: ({ className, appName, params, head, manifest, env, theme, prefix, children, gaTrackingId, fonts, layoutStyle, reconnect, of, }: CSRProviderProps) => import("react/jsx-runtime").JSX.Element;
16
+ declare const CSRProvider: ({ className, appName, params, head, manifest, env, theme, prefix, children, gaTrackingId, fonts, layoutStyle, reconnect, wsConnect, of, }: CSRProviderProps) => import("react/jsx-runtime").JSX.Element;
17
17
  interface CSRWrapperProps {
18
18
  className?: string;
19
19
  appName: string;
@@ -6,7 +6,7 @@ import { type HTMLAttributes, type ReactNode, type RefObject } from "react";
6
6
  export declare const Client: {
7
7
  (): import("react/jsx-runtime").JSX.Element;
8
8
  Wrapper: ({ children, theme, lang, dictionary, signals, reconnect, }: ClientWrapperProps) => import("react/jsx-runtime").JSX.Element;
9
- Bridge: ({ env, lang, theme, prefix, gaTrackingId }: ClientBridgeProps) => "" | import("react/jsx-runtime").JSX.Element | undefined;
9
+ Bridge: ({ env, lang, theme, prefix, gaTrackingId, wsConnect }: ClientBridgeProps) => "" | import("react/jsx-runtime").JSX.Element | undefined;
10
10
  Inner: () => import("react/jsx-runtime").JSX.Element;
11
11
  SsrBridge: ({ lang, prefix }: ClientSsrBridgeProps) => null;
12
12
  };
@@ -36,8 +36,9 @@ interface ClientBridgeProps {
36
36
  theme?: AkanTheme;
37
37
  prefix?: string;
38
38
  gaTrackingId?: string;
39
+ wsConnect?: boolean;
39
40
  }
40
- export declare const ClientBridge: ({ env, lang, theme, prefix, gaTrackingId }: ClientBridgeProps) => "" | import("react/jsx-runtime").JSX.Element | undefined;
41
+ export declare const ClientBridge: ({ env, lang, theme, prefix, gaTrackingId, wsConnect }: ClientBridgeProps) => "" | import("react/jsx-runtime").JSX.Element | undefined;
41
42
  export declare const ClientInner: () => import("react/jsx-runtime").JSX.Element;
42
43
  interface ClientSsrBridgeProps {
43
44
  lang: string;
@@ -28,6 +28,8 @@ export interface ProviderProps {
28
28
  layoutStyle?: "mobile" | "web";
29
29
  /** Enable reconnect helper. Defaults to local operation mode in CSR. */
30
30
  reconnect?: boolean;
31
+ /** Connect the client WebSocket runtime after the browser loads. */
32
+ wsConnect?: boolean;
31
33
  /** Active-locale dictionary injected by the server (SSR only) to seed the client Translator. */
32
34
  dictionary?: Record<string, Record<string, unknown>>;
33
35
  /**
@@ -3,13 +3,13 @@ import { type ReactNode } from "react";
3
3
  import { type ProviderProps } from "./Common.d.ts";
4
4
  export declare const SSR: {
5
5
  (): import("react/jsx-runtime").JSX.Element;
6
- Provider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, dictionary, allDictionary, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
6
+ Provider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, wsConnect, dictionary, allDictionary, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
7
7
  Wrapper: ({ children, head, manifest, fonts, className, prefix, layoutStyle, }: SSRWrapperProps) => import("react/jsx-runtime").JSX.Element;
8
8
  };
9
9
  export type SSRProviderProps = ProviderProps & {
10
10
  fonts?: ReactFont[];
11
11
  };
12
- declare const SSRProvider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, dictionary, allDictionary, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
12
+ declare const SSRProvider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, wsConnect, dictionary, allDictionary, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
13
13
  interface SSRWrapperProps {
14
14
  className?: string;
15
15
  appName: string;
package/ui/System/CSR.tsx CHANGED
@@ -45,6 +45,7 @@ const CSRProvider = ({
45
45
  fonts,
46
46
  layoutStyle = "web",
47
47
  reconnect = getEnv().operationMode === "local",
48
+ wsConnect = true,
48
49
  of,
49
50
  }: CSRProviderProps) => {
50
51
  return (
@@ -72,7 +73,14 @@ const CSRProvider = ({
72
73
  </Client.Wrapper>
73
74
  <Client.Inner />
74
75
  <CSRInner />
75
- <Client.Bridge lang={lang} env={env} theme={theme} prefix={prefix} gaTrackingId={gaTrackingId} />
76
+ <Client.Bridge
77
+ lang={lang}
78
+ env={env}
79
+ theme={theme}
80
+ prefix={prefix}
81
+ gaTrackingId={gaTrackingId}
82
+ wsConnect={wsConnect}
83
+ />
76
84
  <CSRBridge lang={lang} prefix={prefix} />
77
85
  </>
78
86
  )}
@@ -5,6 +5,7 @@ import {
5
5
  clsx,
6
6
  Device,
7
7
  defaultPageState,
8
+ fetch,
8
9
  getPathInfo,
9
10
  initAuth,
10
11
  type Location,
@@ -152,9 +153,10 @@ interface ClientBridgeProps {
152
153
  theme?: AkanTheme;
153
154
  prefix?: string;
154
155
  gaTrackingId?: string;
156
+ wsConnect?: boolean;
155
157
  }
156
158
 
157
- export const ClientBridge = ({ env, lang, theme, prefix, gaTrackingId }: ClientBridgeProps) => {
159
+ export const ClientBridge = ({ env, lang, theme, prefix, gaTrackingId, wsConnect = true }: ClientBridgeProps) => {
158
160
  const uiOperation = st.use.uiOperation();
159
161
  const pathname = st.use.pathname();
160
162
  const params = st.use.params();
@@ -174,6 +176,11 @@ export const ClientBridge = ({ env, lang, theme, prefix, gaTrackingId }: ClientB
174
176
  }, 2000);
175
177
  }, []);
176
178
 
179
+ useEffect(() => {
180
+ if (!wsConnect) return;
181
+ (fetch.instance as { connect: () => void }).connect();
182
+ }, [wsConnect]);
183
+
177
184
  useEffect(() => {
178
185
  if (getThemeCookie() !== undefined) return;
179
186
  applyThemePolicy(theme ?? "system");
@@ -30,6 +30,8 @@ export interface ProviderProps {
30
30
  layoutStyle?: "mobile" | "web";
31
31
  /** Enable reconnect helper. Defaults to local operation mode in CSR. */
32
32
  reconnect?: boolean;
33
+ /** Connect the client WebSocket runtime after the browser loads. */
34
+ wsConnect?: boolean;
33
35
  /** Active-locale dictionary injected by the server (SSR only) to seed the client Translator. */
34
36
  dictionary?: Record<string, Record<string, unknown>>;
35
37
  /**
package/ui/System/SSR.tsx CHANGED
@@ -29,6 +29,7 @@ const SSRProvider = ({
29
29
  fonts,
30
30
  layoutStyle = "web",
31
31
  reconnect = getEnv().operationMode === "local",
32
+ wsConnect = true,
32
33
  dictionary,
33
34
  allDictionary,
34
35
  of,
@@ -65,7 +66,14 @@ const SSRProvider = ({
65
66
  <ClientInner />
66
67
  </Suspense>
67
68
  <Suspense key="client-bridge" fallback={null}>
68
- <ClientBridge key="bridge" env={env} theme={theme} prefix={prefix} gaTrackingId={gaTrackingId} />
69
+ <ClientBridge
70
+ key="bridge"
71
+ env={env}
72
+ theme={theme}
73
+ prefix={prefix}
74
+ gaTrackingId={gaTrackingId}
75
+ wsConnect={wsConnect}
76
+ />
69
77
  <ClientSsrBridge key="ssr-bridge" lang={lang} prefix={prefix} />
70
78
  </Suspense>
71
79
  </ClientWrapper>
@@ -293,6 +293,7 @@ function validateRouteModuleExports(key: string, mod: RouteModule) {
293
293
  "manifest",
294
294
  "theme",
295
295
  "reconnect",
296
+ "wsConnect",
296
297
  "layoutStyle",
297
298
  "gaTrackingId",
298
299
  "Loading",