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.
- package/constant/crystalize.ts +45 -1
- package/constant/deserialize.ts +3 -2
- package/constant/types.ts +5 -1
- package/fetch/client/fetchClient.ts +11 -2
- package/fetch/fetchType/appliedReturn.type.ts +48 -44
- package/fetch/fetchType/endpointFetch.type.ts +6 -3
- package/fetch/fetchType/sliceFetch.type.ts +27 -50
- package/index.ts +25 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +78 -22
- package/server/akanAppHeaders.ts +2 -0
- package/server/assetEncoding.ts +4 -24
- package/server/console.ts +9 -101
- package/server/consoleEvaluator.ts +51 -0
- package/server/consolePasteFilter.ts +91 -0
- package/server/consoleSession.ts +198 -0
- package/server/contentEncoding.ts +83 -0
- package/server/imageOptimizer.ts +122 -12
- package/server/routing/apiRouter.ts +35 -11
- package/server/semaphore.ts +22 -0
- package/server/types.tsx +3 -0
- package/signal/signalContext.ts +164 -33
- package/signal/types.ts +3 -3
- package/store/action.ts +13 -13
- package/types/constant/constantRegistry.d.ts +2 -10
- package/types/constant/crystalize.d.ts +2 -0
- package/types/constant/types.d.ts +3 -3
- package/types/fetch/agentTurn.d.ts +1 -119
- package/types/fetch/fetchType/appliedReturn.type.d.ts +21 -18
- package/types/fetch/fetchType/endpointFetch.type.d.ts +2 -1
- package/types/fetch/fetchType/sliceFetch.type.d.ts +11 -15
- package/types/index.d.ts +23 -0
- package/types/server/assetEncoding.d.ts +1 -1
- package/types/server/console.d.ts +1 -1
- package/types/server/consoleEvaluator.d.ts +2 -0
- package/types/server/consolePasteFilter.d.ts +11 -0
- package/types/server/consoleSession.d.ts +12 -0
- package/types/server/contentEncoding.d.ts +28 -0
- package/types/server/semaphore.d.ts +5 -0
- package/types/server/types.d.ts +2 -0
- package/types/signal/signalContext.d.ts +8 -1
- package/types/signal/types.d.ts +3 -3
- package/types/store/action.d.ts +7 -5
- package/types/store/baseSt.d.ts +2 -6
- package/types/ui/System/Client.d.ts +1 -2
- package/types/ui/System/Common.d.ts +2 -3
- package/ui/Load/Units.tsx +4 -2
- package/ui/System/Client.tsx +6 -2
- package/ui/System/Common.tsx +2 -3
package/constant/crystalize.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/constant/deserialize.ts
CHANGED
|
@@ -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
|
-
|
|
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> =
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
28
|
+
type ServerInitShape<
|
|
29
29
|
RefName extends string,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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`]:
|
|
36
|
+
[K in `${RefName}ObjList`]: LightObj[];
|
|
40
37
|
} & {
|
|
41
|
-
[K in `${RefName}ObjInsight`]:
|
|
38
|
+
[K in `${RefName}ObjInsight`]: InsightObj;
|
|
42
39
|
} & {
|
|
43
|
-
[K in `pageOf${
|
|
40
|
+
[K in `pageOf${CapRefName}`]: number;
|
|
44
41
|
} & {
|
|
45
|
-
[K in `lastPageOf${
|
|
42
|
+
[K in `lastPageOf${CapRefName}`]: number;
|
|
46
43
|
} & {
|
|
47
|
-
[K in `limitOf${
|
|
44
|
+
[K in `limitOf${CapRefName}`]: number;
|
|
48
45
|
} & {
|
|
49
|
-
[K in `queryArgsOf${
|
|
46
|
+
[K in `queryArgsOf${CapRefName}`]: QueryArgs;
|
|
50
47
|
} & {
|
|
51
|
-
[K in `sortOf${
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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 {
|
|
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
|
|
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?:
|
|
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?:
|
|
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?:
|
|
176
|
-
default?: Partial<
|
|
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> = {
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
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.#
|
|
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
|
-
*
|
|
789
|
-
*
|
|
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
|
-
#
|
|
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
|
-
|
|
822
|
+
return (
|
|
797
823
|
trafficChildren.map((child) => child.lastErrorMessage ?? child.lastRestartReason).find(Boolean) ??
|
|
798
|
-
"unknown boot error"
|
|
799
|
-
|
|
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(
|
|
848
|
+
return new Response(page.text, { status: 503, headers: { "cache-control": "no-store" } });
|
|
802
849
|
}
|
|
803
|
-
|
|
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
|
|
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
|
|
872
|
+
<h1>${AkanApp.#escapeHtml(heading)}</h1>
|
|
819
873
|
<pre>${AkanApp.#escapeHtml(detail)}</pre>
|
|
820
|
-
<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
|
-
|
|
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) {
|
package/server/akanAppHeaders.ts
CHANGED
|
@@ -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
|
}
|