akanjs 3.0.0-alpha.67 → 3.0.0-alpha.68

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 (51) hide show
  1. package/constant/crystalize.ts +45 -1
  2. package/constant/deserialize.ts +3 -2
  3. package/constant/types.ts +5 -1
  4. package/fetch/client/fetchClient.ts +11 -2
  5. package/fetch/fetchType/appliedReturn.type.ts +48 -44
  6. package/fetch/fetchType/endpointFetch.type.ts +6 -3
  7. package/fetch/fetchType/sliceFetch.type.ts +27 -50
  8. package/index.ts +25 -0
  9. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  10. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  11. package/package.json +1 -1
  12. package/server/akanApp.ts +78 -22
  13. package/server/akanAppHeaders.ts +2 -0
  14. package/server/assetEncoding.ts +4 -24
  15. package/server/console.ts +9 -101
  16. package/server/consoleEvaluator.ts +51 -0
  17. package/server/consolePasteFilter.ts +91 -0
  18. package/server/consoleSession.ts +198 -0
  19. package/server/contentEncoding.ts +83 -0
  20. package/server/imageOptimizer.ts +122 -12
  21. package/server/routing/apiRouter.ts +35 -11
  22. package/server/semaphore.ts +22 -0
  23. package/server/types.tsx +3 -0
  24. package/signal/signalContext.ts +164 -33
  25. package/signal/types.ts +3 -3
  26. package/store/action.ts +13 -13
  27. package/types/constant/constantRegistry.d.ts +2 -10
  28. package/types/constant/crystalize.d.ts +2 -0
  29. package/types/constant/types.d.ts +3 -3
  30. package/types/fetch/agentTurn.d.ts +1 -119
  31. package/types/fetch/fetchType/appliedReturn.type.d.ts +21 -18
  32. package/types/fetch/fetchType/endpointFetch.type.d.ts +2 -1
  33. package/types/fetch/fetchType/sliceFetch.type.d.ts +11 -15
  34. package/types/index.d.ts +23 -0
  35. package/types/server/assetEncoding.d.ts +1 -1
  36. package/types/server/console.d.ts +1 -1
  37. package/types/server/consoleEvaluator.d.ts +2 -0
  38. package/types/server/consolePasteFilter.d.ts +11 -0
  39. package/types/server/consoleSession.d.ts +12 -0
  40. package/types/server/contentEncoding.d.ts +28 -0
  41. package/types/server/semaphore.d.ts +5 -0
  42. package/types/server/types.d.ts +2 -0
  43. package/types/signal/signalContext.d.ts +8 -1
  44. package/types/signal/types.d.ts +3 -3
  45. package/types/store/action.d.ts +7 -5
  46. package/types/store/baseSt.d.ts +2 -6
  47. package/types/ui/System/Client.d.ts +1 -2
  48. package/types/ui/System/Common.d.ts +2 -3
  49. package/ui/Load/Units.tsx +4 -2
  50. package/ui/System/Client.tsx +6 -2
  51. package/ui/System/Common.tsx +2 -3
@@ -11,6 +11,50 @@ import type { FieldProps } from ".";
11
11
 
12
12
  export type CrystalizeFunc<Model> = (self: GetStateObject<Model>, isChild?: boolean) => Model;
13
13
 
14
+ /**
15
+ * The relation instances one hydration pass has already built, by model class and then document id.
16
+ *
17
+ * A listing hands the same relation to every row that references it — twenty users wearing one avatar — and
18
+ * each row would otherwise build its own copy of it, re-parsing every `Date` on the way. Sharing is safe
19
+ * because a constant model is `[immerable]`: a store write copies before it mutates.
20
+ *
21
+ * Module-scoped rather than passed down, because `crystalize` is reached through a model's own constructor,
22
+ * which has nowhere to carry it. A pass is wholly synchronous, so no other one can observe this mid-flight.
23
+ */
24
+ let sharedInstances: Map<Cls, Map<string, object>> | null = null;
25
+
26
+ /** Runs one hydration pass, sharing a relation instance across every value in it that names the same id. */
27
+ export const withSharedInstances = <T>(hydrate: () => T): T => {
28
+ if (sharedInstances) return hydrate();
29
+ sharedInstances = new Map();
30
+ try {
31
+ return hydrate();
32
+ } finally {
33
+ sharedInstances = null;
34
+ }
35
+ };
36
+
37
+ type ModelCls = Cls<{ set: (obj: object) => object }>;
38
+
39
+ const relationIdOf = (value: object): string | null => {
40
+ const id = (value as { id?: unknown }).id;
41
+ return typeof id === "string" && id ? id : null;
42
+ };
43
+
44
+ const crystalizeModel = (field: FieldProps, value: object): object => {
45
+ const modelRef = field.modelRef as ModelCls;
46
+
47
+ if (value instanceof modelRef) return value;
48
+
49
+ const id = field.isScalar ? null : relationIdOf(value);
50
+ if (!sharedInstances || !id) return new modelRef().set(value);
51
+ const byId = sharedInstances.get(modelRef) ?? new Map<string, object>();
52
+ if (!sharedInstances.has(modelRef)) sharedInstances.set(modelRef, byId);
53
+ const shared = byId.get(id) ?? new modelRef().set(value);
54
+ byId.set(id, shared);
55
+ return shared;
56
+ };
57
+
14
58
  export const crystalize = (field: FieldProps, value: unknown): unknown => {
15
59
  if (value === undefined || value === null) return value as undefined | null;
16
60
  if (field.isArray && Array.isArray(value))
@@ -41,7 +85,7 @@ export const crystalize = (field: FieldProps, value: unknown): unknown => {
41
85
  ]),
42
86
  );
43
87
  }
44
- if (field.isClass) return new (field.modelRef as Cls<{ set: (obj: object) => object }>)().set(value as object);
88
+ if (field.isClass) return crystalizeModel(field, value as object);
45
89
  if (field.modelRef === Date) return dayjs(value as Date);
46
90
  return crystalizeValue(value);
47
91
  };
@@ -1,6 +1,6 @@
1
1
  import { Any, applyFnToArrayObjects, type Cls, FIELD_META, PrimitiveRegistry, type PrimitiveScalar } from "akanjs/base";
2
2
 
3
- import { type ConstantCls, type ConstantModelRef, ConstantRegistry, type FieldProps } from ".";
3
+ import { type ConstantCls, type ConstantModelRef, ConstantRegistry, type FieldProps, withSharedInstances } from ".";
4
4
 
5
5
  const getDeserializeFn = (inputRef: ConstantModelRef | PrimitiveScalar) => {
6
6
  const deserializeFn = PrimitiveRegistry.has(inputRef as Cls)
@@ -66,5 +66,6 @@ export const deserialize = (
66
66
  if (nullable && (value === null || value === undefined)) return null;
67
67
  else if (!nullable && (value === null || value === undefined) && argRef !== Any)
68
68
  throw new Error(`Invalid Value (Nullable) in ${key} ${argRef} for value ${value}`);
69
- return deserializeInput(value, argRef, arrDepth, convertFn) as object[];
69
+
70
+ return withSharedInstances(() => deserializeInput(value, argRef, arrDepth, convertFn)) as object[];
70
71
  };
package/constant/types.ts CHANGED
@@ -30,7 +30,11 @@ export type DocumentModel<T> = unknown extends T
30
30
  : Docify<T>;
31
31
 
32
32
  export type FieldState<T> = T extends { id: string } ? T | null : T;
33
- export type DefaultOf<S> = GetStateObject<{ [K in keyof S]: FieldState<S[K]> }>;
33
+ export type DefaultOf<S> = {
34
+ [K in keyof S as S[K] extends (...args: never[]) => unknown ? never : K extends "prototype" ? never : K]: FieldState<
35
+ S[K]
36
+ >;
37
+ };
34
38
 
35
39
  export type DefaultOfSchema<Schema, RelationKey = never> = [RelationKey] extends [never]
36
40
  ? Schema
@@ -1,6 +1,13 @@
1
1
  import { DataList, getEnv, PrimitiveRegistry, type PromiseOrObject } from "akanjs/base";
2
2
  import { capitalize, type FetchPolicy, fileUploadContract, Logger, resolveFileUploadCapability } from "akanjs/common";
3
- import { type BaseInsight, type BaseObject, ConstantRegistry, deserialize, serialize } from "akanjs/constant";
3
+ import {
4
+ type BaseInsight,
5
+ type BaseObject,
6
+ ConstantRegistry,
7
+ deserialize,
8
+ serialize,
9
+ withSharedInstances,
10
+ } from "akanjs/constant";
4
11
  import type {
5
12
  DatabaseSignal,
6
13
  SerializedArg,
@@ -646,7 +653,9 @@ export class FetchClient {
646
653
  listFn(...fetchQueryArgs, skip, limit, sort, { ...option, crystalize: false }),
647
654
  fetchInsight ? insightFn(...fetchQueryArgs, { ...option, crystalize: false }) : null,
648
655
  ])) as unknown as [BaseObject[], BaseInsight];
649
- const modelList = new DataList(modelObjList.map((modelObj) => new cnst.light(modelObj)));
656
+ const modelList = new DataList(
657
+ withSharedInstances(() => modelObjList.map((modelObj) => new cnst.light(modelObj))),
658
+ );
650
659
  const modelInsight = new cnst.insight(modelObjInsight);
651
660
  const lastPage = modelObjInsight?.count
652
661
  ? Math.max(Math.floor((modelObjInsight.count - 1) / (limit || 20)) + 1, 1)
@@ -25,33 +25,45 @@ export interface QuerySetting {
25
25
  queryArgs?: unknown[] | (() => unknown[]);
26
26
  }
27
27
 
28
- export type ServerInit<
28
+ type ServerInitShape<
29
29
  RefName extends string,
30
- Light,
31
- Insight = any,
32
- QueryArgs = any,
33
- Filter extends FilterInstance = any,
34
- _CapitalizedRefName extends string = Capitalize<RefName>,
35
- _LightObj = GetStateObject<Light>,
36
- _InsightObj = GetStateObject<Insight>,
37
- _Sort = ExtractSort<Filter>,
30
+ QueryArgs,
31
+ CapRefName extends string,
32
+ LightObj,
33
+ InsightObj,
34
+ Sort,
38
35
  > = SliceMeta & {
39
- [K in `${RefName}ObjList`]: _LightObj[];
36
+ [K in `${RefName}ObjList`]: LightObj[];
40
37
  } & {
41
- [K in `${RefName}ObjInsight`]: _InsightObj;
38
+ [K in `${RefName}ObjInsight`]: InsightObj;
42
39
  } & {
43
- [K in `pageOf${_CapitalizedRefName}`]: number;
40
+ [K in `pageOf${CapRefName}`]: number;
44
41
  } & {
45
- [K in `lastPageOf${_CapitalizedRefName}`]: number;
42
+ [K in `lastPageOf${CapRefName}`]: number;
46
43
  } & {
47
- [K in `limitOf${_CapitalizedRefName}`]: number;
44
+ [K in `limitOf${CapRefName}`]: number;
48
45
  } & {
49
- [K in `queryArgsOf${_CapitalizedRefName}`]: QueryArgs;
46
+ [K in `queryArgsOf${CapRefName}`]: QueryArgs;
50
47
  } & {
51
- [K in `sortOf${_CapitalizedRefName}`]: _Sort;
48
+ [K in `sortOf${CapRefName}`]: Sort;
52
49
  } & {
53
50
  [K in `${RefName}InitAt`]: Date;
54
51
  };
52
+
53
+ export type ServerInit<
54
+ RefName extends string,
55
+ Light,
56
+ Insight = any,
57
+ QueryArgs = any,
58
+ Filter extends FilterInstance = any,
59
+ > = ServerInitShape<
60
+ RefName,
61
+ QueryArgs,
62
+ Capitalize<RefName>,
63
+ GetStateObject<Light>,
64
+ GetStateObject<Insight>,
65
+ ExtractSort<Filter>
66
+ >;
55
67
  /** Client/server-friendly return type for initialized list and insight data. */
56
68
  export type ClientInit<
57
69
  RefName extends string,
@@ -59,13 +71,7 @@ export type ClientInit<
59
71
  Insight = any,
60
72
  QueryArgs = any,
61
73
  Filter extends FilterInstance = any,
62
- _CapitalizedRefName extends string = Capitalize<RefName>,
63
- _LightObj = GetStateObject<Light>,
64
- _InsightObj = GetStateObject<Insight>,
65
- _Sort = ExtractSort<Filter>,
66
- > = PromiseOrObject<
67
- ServerInit<RefName, Light, Insight, QueryArgs, Filter, _CapitalizedRefName, _LightObj, _InsightObj, _Sort>
68
- >;
74
+ > = PromiseOrObject<ServerInit<RefName, Light, Insight, QueryArgs, Filter>>;
69
75
 
70
76
  export type ServerView<RefName extends string, Model> = { refName: RefName } & {
71
77
  [K in `${RefName}Obj`]: GetStateObject<Model>;
@@ -94,6 +100,18 @@ export type EditReturn<RefName extends string, Full> = {
94
100
  [K in `${RefName}Edit`]: ServerEdit<RefName, Full>;
95
101
  };
96
102
 
103
+ type InitReturnShape<
104
+ RefName extends string,
105
+ CapSuffix extends string,
106
+ Init,
107
+ ListItem extends { id: string },
108
+ Insight,
109
+ > = {
110
+ [K in `${RefName}Init${CapSuffix}`]: Init;
111
+ } & {
112
+ [K in `${RefName}List${CapSuffix}`]: DataList<ListItem>;
113
+ } & { [K in `${RefName}Insight${CapSuffix}`]: Insight };
114
+
97
115
  export type InitReturn<
98
116
  RefName extends string,
99
117
  Suffix extends string,
@@ -101,25 +119,11 @@ export type InitReturn<
101
119
  Insight,
102
120
  Args,
103
121
  Filter extends FilterInstance,
104
- _CapitalizedRefName extends string = Capitalize<RefName>,
105
- _CapitalizedSuffix extends string = Capitalize<Suffix>,
106
- _Light extends { id: string } = Light extends { id: string } ? Light : { id: string },
107
- _LightObj = GetStateObject<_Light>,
108
- _InsightObj = GetStateObject<Insight>,
109
- _Sort = ExtractSort<Filter>,
110
- > = {
111
- [K in `${RefName}Init${_CapitalizedSuffix}`]: ServerInit<
112
- RefName,
113
- Light,
114
- Insight,
115
- Args,
116
- Filter,
117
- _CapitalizedRefName,
118
- _LightObj,
119
- _InsightObj,
120
- _Sort
121
- >;
122
- } & {
123
- [K in `${RefName}List${_CapitalizedSuffix}`]: DataList<_Light>;
124
- } & { [K in `${RefName}Insight${_CapitalizedSuffix}`]: Insight };
122
+ > = InitReturnShape<
123
+ RefName,
124
+ Capitalize<Suffix>,
125
+ ServerInit<RefName, Light, Insight, Args, Filter>,
126
+ Light extends { id: string } ? Light : { id: string },
127
+ Insight
128
+ >;
125
129
 
@@ -33,9 +33,12 @@ type EndpointClientReturns<E, SlceCls extends SliceCls | never> = [SlceCls] exte
33
33
  SlceCnstInsight<SlceCls>
34
34
  >;
35
35
 
36
- type EndpInfoReturns<E, SlceCls extends SliceCls | never> =
37
- | EndpointClientReturns<E, SlceCls>
38
- | (EndpInfoNullable<E> extends true ? null : never);
36
+ type OrNull<T, Nullable> = Nullable extends true ? T | null : T;
37
+
38
+ type EndpInfoReturns<E, SlceCls extends SliceCls | never> = OrNull<
39
+ EndpointClientReturns<E, SlceCls>,
40
+ EndpInfoNullable<E>
41
+ >;
39
42
 
40
43
  type QueryOrMutationFetchFn<E, SlceCls extends SliceCls | never> = (
41
44
  ...args: [...EndpInfoArgs<E>, fetchPolicy?: FetchPolicy]
@@ -1,10 +1,9 @@
1
- import type { GetStateObject, SLICE_META } from "akanjs/base";
1
+ import type { SLICE_META } from "akanjs/base";
2
2
  import type { FetchPolicy } from "akanjs/common";
3
3
  import type { ConstantModel, DefaultOf, ProtoFile, PurifiedModel } from "akanjs/constant";
4
4
  import type { DatabaseModel, ExtractSort, FilterInstance } from "akanjs/document";
5
5
  import type {
6
6
  SlceCnstCapitalizedRefName,
7
- SlceCnstDefaultInput,
8
7
  SlceCnstFull,
9
8
  SlceCnstInput,
10
9
  SlceCnstInsight,
@@ -26,11 +25,9 @@ type _Full<S extends SliceCls> = SlceCnstFull<S>;
26
25
  type _Light<S extends SliceCls> = SlceCnstLight<S>;
27
26
  type _Insight<S extends SliceCls> = SlceCnstInsight<S>;
28
27
  type _PurifiedInput<S extends SliceCls> = SlceCnstPurifiedInput<S>;
29
- type _DefaultInput<S extends SliceCls> = SlceCnstDefaultInput<S>;
30
28
  type _Filter<S extends SliceCls> = SlceDbFilter<S>;
31
29
  type _Sort<S extends SliceCls> = SlceDbSort<S>;
32
- type _LightWithId<S extends SliceCls> = _Light<S> extends { id: string } ? _Light<S> : { id: string };
33
- type _SliceFetchInitOption<S extends SliceCls> = FetchInitOption<_Input<S>, _Filter<S>, _DefaultInput<S>, _Sort<S>>;
30
+ type SliceInitOption<S extends SliceCls> = FetchInitOption<_Input<S>, _Filter<S>>;
34
31
 
35
32
  type SliceListFetch<S extends SliceCls> = {
36
33
  [Suffix in keyof _SliceMap<S> as Suffix extends string ? `${_RefName<S>}List${Capitalize<Suffix>}` : never]: (
@@ -50,43 +47,33 @@ type SliceInsightFetch<S extends SliceCls> = {
50
47
  ) => Promise<_Insight<S>>;
51
48
  };
52
49
 
50
+ type SliceInit<S extends SliceCls, Suffix extends keyof _SliceMap<S>> = InitReturn<
51
+ _RefName<S>,
52
+ Suffix & string,
53
+ _Light<S>,
54
+ _Insight<S>,
55
+ SliceInfoArgs<_SliceMap<S>[Suffix]>,
56
+ _Filter<S>
57
+ >;
58
+
59
+ type SliceServerInit<S extends SliceCls, Suffix extends keyof _SliceMap<S>> = ServerInit<
60
+ _RefName<S>,
61
+ _Light<S>,
62
+ _Insight<S>,
63
+ SliceInfoArgs<_SliceMap<S>[Suffix]>,
64
+ _Filter<S>
65
+ >;
66
+
53
67
  type SliceInitFetch<S extends SliceCls> = {
54
68
  [Suffix in keyof _SliceMap<S> as Suffix extends string ? `init${_Cap<S>}${Capitalize<Suffix>}` : never]: (
55
- ...args: [...SliceInfoArgs<_SliceMap<S>[Suffix]>, option?: _SliceFetchInitOption<S>]
56
- ) => Promise<
57
- InitReturn<
58
- _RefName<S>,
59
- Suffix & string,
60
- _Light<S>,
61
- _Insight<S>,
62
- SliceInfoArgs<_SliceMap<S>[Suffix]>,
63
- _Filter<S>,
64
- _Cap<S>,
65
- Suffix extends string ? Capitalize<Suffix> : never,
66
- _LightWithId<S>,
67
- GetStateObject<_LightWithId<S>>,
68
- GetStateObject<_Insight<S>>,
69
- _Sort<S>
70
- >
71
- >;
69
+ ...args: [...SliceInfoArgs<_SliceMap<S>[Suffix]>, option?: SliceInitOption<S>]
70
+ ) => Promise<SliceInit<S, Suffix>>;
72
71
  };
73
72
 
74
73
  type SliceGetInitFetch<S extends SliceCls> = {
75
74
  [Suffix in keyof _SliceMap<S> as Suffix extends string ? `get${_Cap<S>}Init${Capitalize<Suffix>}` : never]: (
76
- ...args: [...SliceInfoArgs<_SliceMap<S>[Suffix]>, option?: _SliceFetchInitOption<S>]
77
- ) => Promise<
78
- ServerInit<
79
- _RefName<S>,
80
- _Light<S>,
81
- _Insight<S>,
82
- SliceInfoArgs<_SliceMap<S>[Suffix]>,
83
- _Filter<S>,
84
- _Cap<S>,
85
- GetStateObject<_LightWithId<S>>,
86
- GetStateObject<_Insight<S>>,
87
- _Sort<S>
88
- >
89
- >;
75
+ ...args: [...SliceInfoArgs<_SliceMap<S>[Suffix]>, option?: SliceInitOption<S>]
76
+ ) => Promise<SliceServerInit<S, Suffix>>;
90
77
  };
91
78
 
92
79
  export type GetFetchTypeFromSlice<SlceCls extends SliceCls> = SlceCls["srv"]["cnst"] extends ConstantModel
@@ -164,23 +151,13 @@ type AppliedBaseSliceFetchType<
164
151
  ) => Promise<Full>;
165
152
  };
166
153
 
167
- export interface FetchInitForm<
168
- Input,
169
- Filter extends FilterInstance,
170
- _DefaultInput = DefaultOf<Input>,
171
- _Sort = ExtractSort<Filter>,
172
- > {
154
+ export interface FetchInitForm<Input, Filter extends FilterInstance> {
173
155
  page?: number;
174
156
  limit?: number;
175
- sort?: _Sort;
176
- default?: Partial<_DefaultInput>;
157
+ sort?: ExtractSort<Filter>;
158
+ default?: Partial<DefaultOf<Input>>;
177
159
  invalidate?: boolean;
178
160
  insight?: boolean;
179
161
  }
180
162
 
181
- type FetchInitOption<
182
- Input,
183
- Filter extends FilterInstance,
184
- _DefaultInput = DefaultOf<Input>,
185
- _Sort = ExtractSort<Filter>,
186
- > = FetchPolicy & FetchInitForm<Input, Filter, _DefaultInput, _Sort>;
163
+ type FetchInitOption<Input, Filter extends FilterInstance> = FetchPolicy & FetchInitForm<Input, Filter>;
package/index.ts CHANGED
@@ -65,6 +65,27 @@ export interface AkanWebConfig {
65
65
  */
66
66
  export type AkanWebOption = boolean | { csr: boolean };
67
67
 
68
+ /**
69
+ * How `akan build` trims the `public/` tree it copies into `dist`. Source trees are never touched: an app's
70
+ * and a lib's `public/` keep every file, and only the build's own copy is trimmed.
71
+ */
72
+ export interface AkanAssetsConfig {
73
+ /**
74
+ * Drop font files from the build's `public/` that no built surface references. A font with `optimize` on is
75
+ * served from `/_akan/fonts` after subsetting, so its source is a build input the image never reads.
76
+ */
77
+ pruneFonts: boolean;
78
+ /**
79
+ * Font files to keep whatever the scan concludes, as globs relative to this app's or lib's own `public/`
80
+ * (`"fonts/Assistant-*.woff2"`). For the case a scan cannot see: a URL assembled at runtime. Declare it in
81
+ * the `akan.config.ts` that owns the font, so the reason travels with the lib rather than with the app.
82
+ */
83
+ keepFonts: string[];
84
+ }
85
+
86
+ /** A lib picks only which of its own fonts must survive; whether to prune at all belongs to the app. */
87
+ export type LibAssetsConfig = Pick<AkanAssetsConfig, "keepFonts">;
88
+
68
89
  export type DatabaseMode = "single" | "multiple" | "cluster";
69
90
  export type MobileEnv = "local" | "debug" | "develop" | "main";
70
91
  export type MobilePermission = "camera" | "contacts" | "location" | "push" | "speech";
@@ -220,12 +241,16 @@ export interface AppConfigResult {
220
241
  publicEnv: string[];
221
242
  mobile: AkanMobileConfig;
222
243
  secrets: string[];
244
+ /** How the build trims the `public/` copy it ships. */
245
+ assets: AkanAssetsConfig;
223
246
  }
224
247
 
225
248
  export interface LibConfigResult {
226
249
  externalLibs: string[];
227
250
  /** Image steps every app that mounts this lib inherits, unless that app declares a whole Dockerfile. */
228
251
  docker: LibDockerConfig;
252
+ /** Which of this lib's own public fonts every app that mounts it must keep. */
253
+ assets: LibAssetsConfig;
229
254
  }
230
255
 
231
256
  export type DeepPartial<T> = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.67",
3
+ "version": "3.0.0-alpha.68",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -8,6 +8,7 @@ import { isTraceEnabled } from "../signal/trace";
8
8
  import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
9
9
  import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
10
10
  import { resolveEncodedSidecar } from "./assetEncoding";
11
+ import { compressResponse } from "./contentEncoding";
11
12
  import { isPortInUseError } from "./lifecycle/portInUse";
12
13
  import { resolveRuntimeDir } from "./lifecycle/runtimeDir";
13
14
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
@@ -107,6 +108,7 @@ export class AkanApp {
107
108
  /** Hosted by `akan start`: crash loops should yield to the dev host, which restarts on file edits. */
108
109
  readonly #devHosted = process.env.AKAN_COMMAND_TYPE === "start";
109
110
  readonly #healthTimeoutMs = AkanApp.#parseHealthTimeoutMs();
111
+ readonly #upstreamWaitMs = AkanApp.#parseUpstreamWaitMs();
110
112
  /** Child stderr is bundler/runtime noise the gateway cannot act on; it still reaches the rotating log file. */
111
113
  readonly #printChildStderr = process.env.AKAN_CHILD_STDERR === "1";
112
114
  readonly #serverPath: string;
@@ -230,6 +232,17 @@ export class AkanApp {
230
232
  return process.env.AKAN_COMMAND_TYPE === "start" ? 15_000 : 5_000;
231
233
  }
232
234
 
235
+ /**
236
+ * How long a request waits for a replica that is booting or restarting before the gateway answers
237
+ * 503. Dev pays first-touch transpiles on the boot path, so it gets the wider budget; `0` restores
238
+ * the old fail-immediately behavior.
239
+ */
240
+ static #parseUpstreamWaitMs() {
241
+ const configured = Number(process.env.AKAN_UPSTREAM_WAIT_MS);
242
+ if (Number.isFinite(configured) && configured >= 0) return configured;
243
+ return process.env.AKAN_COMMAND_TYPE === "start" ? 15_000 : 5_000;
244
+ }
245
+
233
246
  /**
234
247
  * Must exceed the child's own shutdown timeout (see `AkanServer.#defaultShutdownTimeoutMs`) so
235
248
  * children always get to exit on their own before this gateway stops waiting.
@@ -742,10 +755,8 @@ export class AkanApp {
742
755
  }
743
756
 
744
757
  async #proxyHttp(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response> {
745
- const child = this.#pickFederationChild();
746
- if (!child?.upstream || child.upstream.type !== "unix") {
747
- return this.#respondWithCrashPage(req) ?? new Response("No healthy federation child is ready", { status: 503 });
748
- }
758
+ const child = await this.#pickReadyFederationChild(req);
759
+ if (!child?.upstream || child.upstream.type !== "unix") return this.#respondWithUnavailable(req);
749
760
  const url = new URL(req.url);
750
761
  const upstreamUrl = `http://akan-child${url.pathname}${url.search}`;
751
762
  const headers = this.#makeProxyHeaders(req, child.idx, server);
@@ -762,7 +773,7 @@ export class AkanApp {
762
773
  signal: req.signal,
763
774
  redirect: "manual",
764
775
  });
765
- return this.#proxyResponse(upstreamRes);
776
+ return await this.#proxyResponse(req, upstreamRes);
766
777
  } catch (error) {
767
778
  if (AkanApp.#isUpstreamOpenFailure(error)) {
768
779
  this.logger.error(
@@ -785,26 +796,69 @@ export class AkanApp {
785
796
  }
786
797
 
787
798
  /**
788
- * Dev-only: every traffic replica is in the crashed terminal state, so a bare 503 would hide the
789
- * boot error from the browser. Surface it, and reload once a fixed gateway takes over the port.
799
+ * A replica that is booting or restarting is normally back within a second or two, and a browser
800
+ * handed a 503 stays on that dead page until somebody reloads by hand — so a request waits for the
801
+ * upstream instead of failing at it. A dev crash loop is terminal, so it is answered immediately.
790
802
  */
791
- #respondWithCrashPage(req: Request): Response | null {
803
+ async #pickReadyFederationChild(req: Request): Promise<ChildState | null> {
804
+ const ready = this.#pickFederationChild();
805
+ if (ready) return ready;
806
+ const deadline = performance.now() + this.#upstreamWaitMs;
807
+ while (performance.now() < deadline) {
808
+ if (this.#stopping || req.signal.aborted || this.#getCrashLoopDetail()) return null;
809
+ await Bun.sleep(50);
810
+ const child = this.#pickFederationChild();
811
+ if (child) return child;
812
+ }
813
+ return null;
814
+ }
815
+
816
+ /** Dev-only terminal state: every traffic replica gave up booting. The detail is the boot error. */
817
+ #getCrashLoopDetail(): string | null {
792
818
  if (!this.#devHosted) return null;
793
819
  const trafficChildren = [...this.#children.values()].filter((child) => child.role !== "batch");
794
820
  if (trafficChildren.length === 0) return null;
795
821
  if (!trafficChildren.every((child) => child.status === "crashed")) return null;
796
- const detail =
822
+ return (
797
823
  trafficChildren.map((child) => child.lastErrorMessage ?? child.lastRestartReason).find(Boolean) ??
798
- "unknown boot error";
799
- const message = `Backend failed to start after ${AkanApp.#devMaxChildBootFailures} boot attempts: ${detail}`;
824
+ "unknown boot error"
825
+ );
826
+ }
827
+
828
+ /**
829
+ * Both states answer 503, so an ingress or CDN reads them exactly as before; the HTML body is what
830
+ * a browser navigation needs, since nothing on a bare-text 503 can bring the page back on its own.
831
+ */
832
+ #respondWithUnavailable(req: Request): Response {
833
+ const crashDetail = this.#getCrashLoopDetail();
834
+ const page = crashDetail
835
+ ? {
836
+ heading: "Backend failed to start",
837
+ detail: crashDetail,
838
+ text: `Backend failed to start after ${AkanApp.#devMaxChildBootFailures} boot attempts: ${crashDetail}`,
839
+ note: `The dev server stopped retrying after ${AkanApp.#devMaxChildBootFailures} failed boots. Fix the error and save — this page reloads automatically.`,
840
+ }
841
+ : {
842
+ heading: "Backend is starting",
843
+ detail: "No healthy federation child is ready",
844
+ text: "No healthy federation child is ready",
845
+ note: "A replica is booting or restarting — this page reloads itself as soon as it answers.",
846
+ };
800
847
  if (!req.headers.get("accept")?.includes("text/html")) {
801
- return new Response(message, { status: 503, headers: { "cache-control": "no-store" } });
848
+ return new Response(page.text, { status: 503, headers: { "cache-control": "no-store" } });
802
849
  }
803
- const html = `<!doctype html>
850
+ return new Response(AkanApp.#statusPageHtml(page), {
851
+ status: 503,
852
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
853
+ });
854
+ }
855
+
856
+ static #statusPageHtml({ heading, detail, note }: { heading: string; detail: string; note: string }) {
857
+ return `<!doctype html>
804
858
  <html>
805
859
  <head>
806
860
  <meta charset="utf-8" />
807
- <title>Backend failed to start</title>
861
+ <title>${AkanApp.#escapeHtml(heading)}</title>
808
862
  <style>
809
863
  body { margin: 0; padding: 48px 24px; background: #111827; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; }
810
864
  main { max-width: 720px; margin: 0 auto; }
@@ -815,9 +869,9 @@ export class AkanApp {
815
869
  </head>
816
870
  <body>
817
871
  <main>
818
- <h1>Backend failed to start</h1>
872
+ <h1>${AkanApp.#escapeHtml(heading)}</h1>
819
873
  <pre>${AkanApp.#escapeHtml(detail)}</pre>
820
- <p>The dev server stopped retrying after ${AkanApp.#devMaxChildBootFailures} failed boots. Fix the error and save &mdash; this page reloads automatically.</p>
874
+ <p>${AkanApp.#escapeHtml(note)}</p>
821
875
  </main>
822
876
  <script>
823
877
  const poll = async () => {
@@ -831,10 +885,6 @@ export class AkanApp {
831
885
  </script>
832
886
  </body>
833
887
  </html>`;
834
- return new Response(html, {
835
- status: 503,
836
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
837
- });
838
888
  }
839
889
 
840
890
  static #escapeHtml(text: string) {
@@ -858,17 +908,23 @@ export class AkanApp {
858
908
  this.#proxyHopMaxMs = Math.max(this.#proxyHopMaxMs, durationMs);
859
909
  }
860
910
 
861
- #proxyResponse(upstreamRes: Response): Response {
911
+ async #proxyResponse(req: Request, upstreamRes: Response): Promise<Response> {
862
912
  const headers = new Headers(upstreamRes.headers);
863
913
 
864
914
  headers.delete("content-encoding");
865
915
  headers.delete("content-length");
866
916
  this.#rewriteInternalLocation(headers);
867
- return new Response(upstreamRes.body, {
917
+ const proxied = new Response(upstreamRes.body, {
868
918
  status: upstreamRes.status,
869
919
  statusText: upstreamRes.statusText,
870
920
  headers,
871
921
  });
922
+
923
+ return AkanApp.#isProxiedJson(headers) ? await compressResponse(req, proxied) : proxied;
924
+ }
925
+
926
+ static #isProxiedJson(headers: Headers): boolean {
927
+ return (headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() === "application/json";
872
928
  }
873
929
 
874
930
  #rewriteInternalLocation(headers: Headers) {
@@ -34,6 +34,8 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
34
34
  headers.get("x-forwarded-proto") ?? (req.url.startsWith("https:") ? "https" : "http"),
35
35
  );
36
36
  headers.set("x-akan-child-idx", String(childIdx));
37
+
38
+ headers.set("accept-encoding", "identity");
37
39
  if (!headers.has("x-request-id") && process.env.AKAN_BENCH_SKIP_REQUEST_ID !== "1") {
38
40
  headers.set("x-request-id", crypto.randomUUID());
39
41
  }