akanjs 2.2.7 → 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/client/clientRuntime.ts +3 -0
  3. package/client/csrTypes.ts +1 -0
  4. package/client/makePageProto.tsx +5 -2
  5. package/client/translator.ts +7 -4
  6. package/common/fileUpload.ts +1 -1
  7. package/common/index.ts +5 -1
  8. package/constant/getDefault.ts +1 -1
  9. package/dictionary/base.dictionary.ts +0 -1
  10. package/fetch/client/fetchClient.ts +21 -24
  11. package/fetch/client/wsClient.ts +8 -0
  12. package/fetch/serializer/fetch.serializer.ts +1 -0
  13. package/package.json +1 -5
  14. package/server/hmr/devHmrController.ts +1 -0
  15. package/server/routeTreeBuilder.ts +1 -0
  16. package/server/ssrFromRscRenderer.tsx +34 -12
  17. package/server/ssrTypes.ts +5 -6
  18. package/service/base.service.ts +0 -4
  19. package/service/injectInfo.ts +49 -12
  20. package/service/predefinedAdaptor/cache.adaptor.ts +13 -0
  21. package/service/predefinedAdaptor/database.adaptor.ts +74 -16
  22. package/service/predefinedAdaptor/solidCache.adaptor.ts +23 -0
  23. package/signal/base.signal.ts +0 -5
  24. package/signal/serializer/fetch.serializer.ts +1 -0
  25. package/signal/types.ts +3 -0
  26. package/store/action.ts +15 -3
  27. package/store/storeInstance.ts +50 -3
  28. package/types/client/csrTypes.d.ts +1 -0
  29. package/types/client/translator.d.ts +1 -0
  30. package/types/common/fileUpload.d.ts +1 -1
  31. package/types/common/index.d.ts +1 -1
  32. package/types/server/ssrTypes.d.ts +5 -6
  33. package/types/service/base.service.d.ts +0 -1
  34. package/types/service/injectInfo.d.ts +8 -2
  35. package/types/service/predefinedAdaptor/cache.adaptor.d.ts +6 -0
  36. package/types/service/predefinedAdaptor/database.adaptor.d.ts +3 -1
  37. package/types/service/predefinedAdaptor/solidCache.adaptor.d.ts +3 -0
  38. package/types/signal/base.signal.d.ts +0 -3
  39. package/types/signal/types.d.ts +3 -0
  40. package/types/ui/Dialog/Modal.d.ts +1 -1
  41. package/types/ui/Dialog/index.d.ts +1 -1
  42. package/types/ui/Modal.d.ts +1 -12
  43. package/types/ui/System/CSR.d.ts +2 -2
  44. package/types/ui/System/Client.d.ts +5 -4
  45. package/types/ui/System/Common.d.ts +2 -0
  46. package/types/ui/System/SSR.d.ts +2 -2
  47. package/ui/Dialog/Modal.tsx +181 -70
  48. package/ui/Dialog/Provider.tsx +3 -6
  49. package/ui/Modal.tsx +0 -44
  50. package/ui/System/CSR.tsx +9 -1
  51. package/ui/System/Client.tsx +27 -62
  52. package/ui/System/Common.tsx +2 -0
  53. package/ui/System/SSR.tsx +9 -1
  54. package/webkit/bootCsr.tsx +1 -0
package/CHANGELOG.md CHANGED
@@ -4,8 +4,17 @@
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - fix: base dictionary translation failed in some cases
8
- - fix: file upload contract workaround on shared Field.Img component
7
+ - bf51564: fix: base dictionary translation failed in some cases
8
+ - bf51564: fix: file upload contract workaround on shared Field.Img component
9
+
10
+ ## 2.2.5
11
+
12
+ ### Patch Changes
13
+
14
+ - d636456: add rich Map methods on memory() helper service
15
+ - a1ee4e8: fill nested constant defaults for arrays on document save and load, normalize date fields to a consistent epoch representation on store (accepting legacy ISO-string values on read), and correct falsy defaults in getDefault
16
+ - 5cdb05e: reverse dependency of file upload api
17
+ - a7da50e: remove dependency from radix dialog
9
18
 
10
19
  ## 2.2.3
11
20
 
@@ -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;
@@ -25,8 +25,11 @@ const getPageInfo = (): { locale: string; path: string } => {
25
25
  const localeSet = new Set(locales);
26
26
  if (getEnv().side !== "server") {
27
27
  const [, firstSegment = "", ...rest] = window.location.pathname.split("/");
28
- if (localeSet.has(firstSegment)) return { locale: firstSegment, path: `/${rest.join("/")}` };
29
- return { locale: defaultLocale, path: window.location.pathname };
28
+ const hasLocalePrefix = localeSet.has(firstSegment);
29
+
30
+ const activeLocale = Translator.getActiveLocale();
31
+ const locale = activeLocale ?? (hasLocalePrefix ? firstSegment : defaultLocale);
32
+ return { locale, path: hasLocalePrefix ? `/${rest.join("/")}` : window.location.pathname };
30
33
  }
31
34
  const h = headers();
32
35
 
@@ -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);
@@ -2,7 +2,7 @@ interface FileUploadSerializedEndpoint {
2
2
  fileUpload?: boolean;
3
3
  }
4
4
 
5
- export interface FileUploadSerializedSignal {
5
+ interface FileUploadSerializedSignal {
6
6
  prefix?: string;
7
7
  endpoint: Record<string, FileUploadSerializedEndpoint>;
8
8
  }
package/common/index.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export { applyMixins } from "./applyMixins";
2
2
  export { capitalize } from "./capitalize";
3
3
  export { deepObjectify } from "./deepObjectify";
4
- export * from "./fileUpload";
4
+ export {
5
+ type FileUploadCapability,
6
+ fileUploadContract,
7
+ resolveFileUploadCapability,
8
+ } from "./fileUpload";
5
9
  export { formatNumber } from "./formatNumber";
6
10
  export { formatPhone } from "./formatPhone";
7
11
  export { getAllPropertyDescriptors } from "./getAllPropertyDescriptors";
@@ -6,7 +6,7 @@ export const getDefault = <T>(fieldObj: FieldObject): DefaultOf<T> => {
6
6
  const result: Record<string, unknown> = {};
7
7
  for (const [key, field] of Object.entries(fieldObj)) {
8
8
  if (field.fieldType === "hidden") result[key] = null;
9
- else if (field.default) {
9
+ else if (field.default !== undefined && field.default !== null) {
10
10
  if (typeof field.default === "function") result[key] = (field.default as () => object)();
11
11
  else result[key] = field.default as object;
12
12
  } else if (field.isArray) result[key] = [];
@@ -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) => ({
@@ -1,12 +1,5 @@
1
1
  import { DataList, getEnv, PrimitiveRegistry, type PromiseOrObject } from "akanjs/base";
2
- import {
3
- capitalize,
4
- type FetchPolicy,
5
- type FileUploadSerializedSignal,
6
- fileUploadContract,
7
- Logger,
8
- resolveFileUploadCapability,
9
- } from "akanjs/common";
2
+ import { capitalize, type FetchPolicy, fileUploadContract, Logger, resolveFileUploadCapability } from "akanjs/common";
10
3
  import { type BaseInsight, type BaseObject, ConstantRegistry, deserialize, serialize } from "akanjs/constant";
11
4
  import type {
12
5
  DatabaseSignal,
@@ -180,6 +173,10 @@ export class FetchClient {
180
173
  if (!handler) throw new Error(`${owner} requires fetch handler "${key}", but it is not registered`);
181
174
  return handler as T;
182
175
  }
176
+ #setHandlerFactory(key: string, factory: FetchHandlerFactory) {
177
+ this.#handlerFactory.set(key, factory);
178
+ delete this.#handlerStore[key];
179
+ }
183
180
  connect() {
184
181
  this.ws.connect();
185
182
  }
@@ -263,15 +260,15 @@ export class FetchClient {
263
260
  #registerEndpoint(key: string, endpoint: SerializedEndpoint, prefix?: string) {
264
261
  switch (endpoint.type) {
265
262
  case "query": {
266
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, endpoint, prefix));
263
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, endpoint, prefix));
267
264
  return;
268
265
  }
269
266
  case "mutation": {
270
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, endpoint, prefix));
267
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, endpoint, prefix));
271
268
  return;
272
269
  }
273
270
  case "pubsub": {
274
- this.#handlerFactory.set(`subscribe${capitalize(key)}`, () => {
271
+ this.#setHandlerFactory(`subscribe${capitalize(key)}`, () => {
275
272
  const roomArgs = endpoint.args.filter((arg) => arg.type === "room");
276
273
  const roomArgLength = roomArgs.length;
277
274
  const serializerMap = this.#makeArgSerializer(endpoint.args);
@@ -299,7 +296,7 @@ export class FetchClient {
299
296
  return;
300
297
  }
301
298
  case "message": {
302
- this.#handlerFactory.set(key, () => {
299
+ this.#setHandlerFactory(key, () => {
303
300
  const msgArgs = endpoint.args.filter((arg) => arg.type === "msg");
304
301
  const msgArgLength = msgArgs.length;
305
302
  const serializerMap = this.#makeArgSerializer(endpoint.args);
@@ -309,7 +306,7 @@ export class FetchClient {
309
306
  this.ws.emit(key, data);
310
307
  };
311
308
  });
312
- this.#handlerFactory.set(`listen${capitalize(key)}`, () => {
309
+ this.#setHandlerFactory(`listen${capitalize(key)}`, () => {
313
310
  const parseReturn = this.#makeReturnParser(endpoint.returns);
314
311
  const wrappedListeners = new WeakMap<(data: unknown) => void, (data: unknown) => void>();
315
312
  return ((handleEvent: (data: unknown) => void, fetchPolicy: FetchPolicy = {}) => {
@@ -414,11 +411,11 @@ export class FetchClient {
414
411
  };
415
412
  const endpoint = FetchClient.getBaseEndpoint(refName, signal);
416
413
  Object.entries(endpoint).forEach(([key, value]) => {
417
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, value, signal.prefix));
414
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, signal.prefix));
418
415
  });
419
416
 
420
417
  if (signal.cruGuards) {
421
- this.#handlerFactory.set(
418
+ this.#setHandlerFactory(
422
419
  names.viewModel,
423
420
  () =>
424
421
  (async (id: string, option?: FetchPolicy) => {
@@ -432,7 +429,7 @@ export class FetchClient {
432
429
  };
433
430
  }) as FetchHandler,
434
431
  );
435
- this.#handlerFactory.set(
432
+ this.#setHandlerFactory(
436
433
  names.getModelView,
437
434
  () =>
438
435
  (async (id: string, option?: FetchPolicy) => {
@@ -441,7 +438,7 @@ export class FetchClient {
441
438
  return { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() };
442
439
  }) as FetchHandler,
443
440
  );
444
- this.#handlerFactory.set(
441
+ this.#setHandlerFactory(
445
442
  names.editModel,
446
443
  () =>
447
444
  (async (id: string, option?: FetchPolicy) => {
@@ -455,7 +452,7 @@ export class FetchClient {
455
452
  };
456
453
  }) as FetchHandler,
457
454
  );
458
- this.#handlerFactory.set(
455
+ this.#setHandlerFactory(
459
456
  names.getModelEdit,
460
457
  () =>
461
458
  (async (id: string, option?: FetchPolicy) => {
@@ -464,7 +461,7 @@ export class FetchClient {
464
461
  return { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() };
465
462
  }) as FetchHandler,
466
463
  );
467
- this.#handlerFactory.set(
464
+ this.#setHandlerFactory(
468
465
  names.mergeModel,
469
466
  () =>
470
467
  (async (modelOrId: string | { id: string }, data: UnknownRecord, option?: FetchPolicy) => {
@@ -475,11 +472,11 @@ export class FetchClient {
475
472
  );
476
473
  }
477
474
 
478
- this.#handlerFactory.set(
475
+ this.#setHandlerFactory(
479
476
  names.addModelFiles,
480
477
  () =>
481
478
  (async (fileList: FileList, parentId?: string, option?: FetchPolicy) => {
482
- const cap = resolveFileUploadCapability(this.serializedSignal as Record<string, FileUploadSerializedSignal>);
479
+ const cap = resolveFileUploadCapability(this.serializedSignal);
483
480
  const endpoint = cap ? this.serializedSignal[cap.refName]?.endpoint[cap.endpointKey] : undefined;
484
481
  if (!cap || !endpoint)
485
482
  throw new Error(
@@ -536,12 +533,12 @@ export class FetchClient {
536
533
 
537
534
  const endpoint = FetchClient.getEndpointFromSlice(refName, suffix, slice);
538
535
  Object.entries(endpoint).forEach(([key, value]) => {
539
- this.#handlerFactory.set(key, () => this.#makeHttpFn(key, value, prefix));
536
+ this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, prefix));
540
537
  });
541
538
 
542
539
  const argLength = slice.args.length;
543
540
  this.slice[sliceName] = { refName, sliceName, argLength };
544
- this.#handlerFactory.set(names.init, () => async (...argData: unknown[]) => {
541
+ this.#setHandlerFactory(names.init, () => async (...argData: unknown[]) => {
545
542
  const cnst = ConstantRegistry.getDatabase(refName);
546
543
  const queryArgs = normalizeQueryArgs(
547
544
  Array.from({ length: Math.min(argData.length, argLength) }, (_, idx) => argData[idx]),
@@ -583,7 +580,7 @@ export class FetchClient {
583
580
  [`${refName}Insight${capSuffix}`]: modelInsight,
584
581
  };
585
582
  });
586
- this.#handlerFactory.set(names.getInit, () => async (...args: unknown[]) => {
583
+ this.#setHandlerFactory(names.getInit, () => async (...args: unknown[]) => {
587
584
  const initFn = this.#requireHandler<(...args: unknown[]) => Promise<Record<string, unknown>>>(
588
585
  names.init,
589
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) {
@@ -67,6 +67,7 @@ export class FetchSerializer {
67
67
  ...(endpointInfo.signalOption.globalPrefix !== undefined
68
68
  ? { globalPrefix: endpointInfo.signalOption.globalPrefix }
69
69
  : {}),
70
+ ...(endpointInfo.signalOption.fileUpload ? { fileUpload: true } : {}),
70
71
  ...(guards?.length ? { guards } : {}),
71
72
  };
72
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.2.7",
3
+ "version": "2.2.9",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -175,7 +175,6 @@
175
175
  "@capgo/capacitor-updater": "^8.46.1",
176
176
  "@libsql/client": "^0.17.3",
177
177
  "@playwright/test": "^1.60.0",
178
- "@radix-ui/react-dialog": "^1.1.15",
179
178
  "@react-spring/web": "^10.1.0",
180
179
  "@use-gesture/react": "^10.3.1",
181
180
  "bullmq": "^5.76.10",
@@ -250,9 +249,6 @@
250
249
  "@playwright/test": {
251
250
  "optional": true
252
251
  },
253
- "@radix-ui/react-dialog": {
254
- "optional": true
255
- },
256
252
  "@react-spring/web": {
257
253
  "optional": true
258
254
  },
@@ -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) };
@@ -254,20 +254,47 @@ export class InjectInfo<
254
254
  enumerable: true,
255
255
  });
256
256
  } else if (injectInfo.isMap) {
257
+ const topic = `akan:memory:${injectInfo.parentRefName}`;
258
+ const getter = injectInfo.get as unknown as (value: unknown) => unknown;
259
+ const setter = injectInfo.set as unknown as (value: unknown) => string | number | Buffer;
260
+ const get = async (key: string) => {
261
+ const value = await cacheAdaptor.hget(topic, propKey, key);
262
+ return value === undefined || value === null ? undefined : getter(value);
263
+ };
264
+ const set = async (key: string, value: unknown) => {
265
+ const setValue = setter(value);
266
+ await cacheAdaptor.hset(topic, propKey, key, setValue);
267
+ };
257
268
  Object.defineProperty(instance, propKey, {
258
269
  value: {
259
- get: async (key: string) => {
260
- const getter = injectInfo.get as unknown as (value: unknown) => unknown;
261
- const value = await cacheAdaptor.hget(`akan:memory:${injectInfo.parentRefName}`, propKey, key);
262
- return value === null ? value : getter(value);
270
+ get,
271
+ set,
272
+ delete: async (key: string) => {
273
+ await cacheAdaptor.hdelete(topic, propKey, key);
263
274
  },
264
- set: async (key: string, value: unknown) => {
265
- const setter = injectInfo.set as unknown as (value: unknown) => string | number | Buffer;
266
- const setValue = setter(value);
267
- await cacheAdaptor.hset(`akan:memory:${injectInfo.parentRefName}`, propKey, key, setValue);
275
+ getOrInsert: async (key: string, value: unknown) => {
276
+ const existingValue = await get(key);
277
+ if (existingValue !== undefined) return existingValue;
278
+ await set(key, value);
279
+ return value;
268
280
  },
269
- delete: async (key: string) => {
270
- await cacheAdaptor.hdelete(`akan:memory:${injectInfo.parentRefName}`, propKey, key);
281
+ getOrInsertComputed: async (key: string, compute: (key: string) => unknown | Promise<unknown>) => {
282
+ const existingValue = await get(key);
283
+ if (existingValue !== undefined) return existingValue;
284
+ const value = await compute(key);
285
+ await set(key, value);
286
+ return value;
287
+ },
288
+ keys: async () => await cacheAdaptor.hkeys(topic, propKey),
289
+ entries: async () => {
290
+ const entries = await cacheAdaptor.hentries(topic, propKey);
291
+ return entries.map(([key, value]) => [key, getter(value)]);
292
+ },
293
+ forEach: async (callback: (value: unknown, key: string) => void | Promise<void>) => {
294
+ for (const [key, value] of await cacheAdaptor.hentries(topic, propKey)) await callback(getter(value), key);
295
+ },
296
+ clear: async () => {
297
+ await cacheAdaptor.hclear(topic, propKey);
271
298
  },
272
299
  },
273
300
  });
@@ -346,6 +373,7 @@ export const injectionBuilder = (parentRefName: string) => ({
346
373
  const isMap = modelRef === Map;
347
374
  if (isMap && !opts.of) throw new Error("of should be provided when modelRef is Map");
348
375
  type FieldValue = never extends GetFn ? GetFieldValue<ValueRef, ExplicitType, MapValue> : ReturnType<GetFn>;
376
+ type MapFieldValue = never extends GetFn ? FieldToValue<MapValue> : ReturnType<GetFn>;
349
377
  type IsNullable = DefaultValue extends never ? true : false;
350
378
  type UseValue = IsNullable extends true ? FieldValue | null : FieldValue;
351
379
  return new InjectInfo<
@@ -356,9 +384,18 @@ export const injectionBuilder = (parentRefName: string) => ({
356
384
  : UseValue
357
385
  : MapConstructor extends ValueRef
358
386
  ? {
359
- get: (key: string) => Promise<FieldToValue<MapValue>>;
360
- set: (key: string, value: FieldToValue<MapValue>) => Promise<void>;
387
+ get: (key: string) => Promise<MapFieldValue | undefined>;
388
+ set: (key: string, value: MapFieldValue) => Promise<void>;
361
389
  delete: (key: string) => Promise<void>;
390
+ getOrInsert: (key: string, value: MapFieldValue) => Promise<MapFieldValue>;
391
+ getOrInsertComputed: (
392
+ key: string,
393
+ compute: (key: string) => MapFieldValue | Promise<MapFieldValue>,
394
+ ) => Promise<MapFieldValue>;
395
+ keys: () => Promise<string[]>;
396
+ entries: () => Promise<[string, MapFieldValue][]>;
397
+ forEach: (callback: (value: MapFieldValue, key: string) => void | Promise<void>) => Promise<void>;
398
+ clear: () => Promise<void>;
362
399
  }
363
400
  : { get: () => Promise<UseValue>; set: (value: UseValue) => Promise<void>; delete: () => Promise<void> },
364
401
  never,
@@ -16,6 +16,9 @@ export interface CacheAdaptor {
16
16
  ): Promise<void>;
17
17
  hget<T extends string | number | Buffer>(topic: string, key: string, subKey: string): Promise<T | undefined>;
18
18
  hdelete(topic: string, key: string, subKey: string): Promise<void>;
19
+ hkeys(topic: string, key: string): Promise<string[]>;
20
+ hentries<T extends string | number | Buffer>(topic: string, key: string): Promise<[string, T][]>;
21
+ hclear(topic: string, key: string): Promise<void>;
19
22
  }
20
23
 
21
24
  interface RedisEnv extends BaseEnv {
@@ -96,6 +99,16 @@ export class RedisCache
96
99
  async hdelete(topic: string, key: string, subKey: string): Promise<void> {
97
100
  await this.redis.hdel(`${topic}:${key}`, subKey);
98
101
  }
102
+ async hkeys(topic: string, key: string): Promise<string[]> {
103
+ return await this.redis.hkeys(`${topic}:${key}`);
104
+ }
105
+ async hentries<T extends string | number | Buffer>(topic: string, key: string): Promise<[string, T][]> {
106
+ const values = await this.redis.hgetall(`${topic}:${key}`);
107
+ return Object.entries(values) as [string, T][];
108
+ }
109
+ async hclear(topic: string, key: string): Promise<void> {
110
+ await this.redis.del(`${topic}:${key}`);
111
+ }
99
112
  getClient(): Redis {
100
113
  return this.redis;
101
114
  }