ignotum 0.0.11 → 0.0.13
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/README.md +8 -3
- package/dist/cli/bin.mjs +1372 -492
- package/dist/cli/bin.mjs.map +1 -1
- package/dist/runtime/{api-DzcR7spt.js → api-CAgKDij7.js} +38 -2
- package/dist/runtime/api-CAgKDij7.js.map +1 -0
- package/dist/runtime/{api-DR_8vfKg.d.ts → api-Cv3hMbzo.d.ts} +4 -3
- package/dist/runtime/client.d.ts +39 -8
- package/dist/runtime/client.js +505 -131
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +154 -0
- package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +1 -0
- package/dist/runtime/id-Bt9XWRGL.js +423 -0
- package/dist/runtime/id-Bt9XWRGL.js.map +1 -0
- package/dist/runtime/{id-Cs82tq9Q-hmYFyqTa.d.ts → id-BzFHf3Wo-DGPCjgrf.d.ts} +13 -13
- package/dist/runtime/id-Dz0apuB3.d.ts +1 -0
- package/dist/runtime/{index-2q9FwJud.d.ts → index-D54flWtH.d.ts} +136 -76
- package/dist/runtime/internal/api.d.ts +1 -1
- package/dist/runtime/internal/api.js +1 -1
- package/dist/runtime/internal/host.d.ts +21 -17
- package/dist/runtime/internal/host.js +22 -8
- package/dist/runtime/internal/host.js.map +1 -1
- package/dist/runtime/internal/server.d.ts +1 -1
- package/dist/runtime/internal/server.js +1 -1
- package/dist/runtime/internal/types.d.ts +1 -1
- package/dist/runtime/internal/types.js +1 -1
- package/dist/runtime/{pagination-Bt3l7QaC-BYEGmLBE.d.ts → pagination-DnKg3dkI-r5ZUxBBx.d.ts} +34 -6
- package/dist/runtime/pagination-Dz0apuB3.d.ts +1 -0
- package/dist/runtime/result-DKAA4gpS.d.ts +1 -0
- package/dist/runtime/{schema-ERFjT8-m.js → schema-1Zs03-iS.js} +135 -17
- package/dist/runtime/schema-1Zs03-iS.js.map +1 -0
- package/dist/runtime/server.d.ts +9 -3
- package/dist/runtime/server.js +5 -3
- package/dist/runtime/server.js.map +1 -1
- package/dist/runtime/{sync-a2EdGSdY.d.ts → sync-Bs8J3fIr.d.ts} +2 -2
- package/package.json +4 -4
- package/src/cli/agent-files.ts +8 -0
- package/src/cli/auth-client.ts +1 -1
- package/src/cli/bin.ts +13 -3
- package/src/cli/build/server.ts +102 -13
- package/src/cli/codegen.ts +7 -4
- package/src/cli/control-client.ts +30 -8
- package/src/cli/deploy.ts +7 -2
- package/src/cli/environment.ts +133 -0
- package/src/client/files.ts +43 -27
- package/src/client/hooks.ts +94 -17
- package/src/client/id.ts +259 -0
- package/src/client/index.ts +5 -2
- package/src/client/page-observers.ts +77 -0
- package/src/client/sync.ts +214 -69
- package/src/dev-runtime/functions.ts +232 -86
- package/src/dev-runtime/id.ts +174 -2
- package/src/dev-runtime/query-cache.ts +105 -0
- package/src/dev-runtime/sync.ts +187 -26
- package/src/server/index.ts +13 -1
- package/dist/runtime/api-DzcR7spt.js.map +0 -1
- package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js +0 -311
- package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js.map +0 -1
- package/dist/runtime/file-C1abuMgd.js +0 -173
- package/dist/runtime/file-C1abuMgd.js.map +0 -1
- package/dist/runtime/pagination-CFJ3xlAt.d.ts +0 -1
- package/dist/runtime/schema-ERFjT8-m.js.map +0 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Array, ConfigProvider, Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decodeDefinedEnvironment,
|
|
5
|
+
emptyEnv,
|
|
6
|
+
type DefinedEnv,
|
|
7
|
+
type EnvironmentType,
|
|
8
|
+
} from "@ignotum/contracts/schema";
|
|
9
|
+
|
|
10
|
+
export class EnvironmentConfigurationInvalid extends Schema.TaggedError<EnvironmentConfigurationInvalid>()(
|
|
11
|
+
"EnvironmentConfigurationInvalid",
|
|
12
|
+
{
|
|
13
|
+
message: Schema.String,
|
|
14
|
+
path: Schema.String,
|
|
15
|
+
},
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
export interface LoadedEnvironment<Environment extends DefinedEnv = DefinedEnv> {
|
|
19
|
+
readonly definition: Environment;
|
|
20
|
+
readonly raw: Readonly<Record<string, string>>;
|
|
21
|
+
readonly value: Readonly<EnvironmentType<Environment>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RawEnvironment {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly raw: Readonly<Record<string, string>>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const collectKeys = Effect.fn("Environment.collectKeys")(function* (
|
|
30
|
+
provider: ConfigProvider.ConfigProvider,
|
|
31
|
+
providerPath: ConfigProvider.Path = [],
|
|
32
|
+
nameParts: ReadonlyArray<string> = [],
|
|
33
|
+
): Effect.fn.Return<ReadonlyArray<string>, ConfigProvider.SourceError, never> {
|
|
34
|
+
const node = yield* provider.load(providerPath);
|
|
35
|
+
if (node === undefined) return [];
|
|
36
|
+
const own = node._tag === "Value" || node.value !== undefined ? [nameParts.join("_")] : [];
|
|
37
|
+
if (node._tag === "Value") return own;
|
|
38
|
+
if (node._tag === "Array") {
|
|
39
|
+
const children = yield* Effect.forEach(Array.range(0, node.length - 1), (index) =>
|
|
40
|
+
collectKeys(provider, [...providerPath, index], [...nameParts, index.toString()]),
|
|
41
|
+
);
|
|
42
|
+
return [...own, ...Array.flatten(children)];
|
|
43
|
+
}
|
|
44
|
+
const children = yield* Effect.forEach(Array.fromIterable(node.keys), (key) =>
|
|
45
|
+
collectKeys(provider, [...providerPath, key], [...nameParts, key]),
|
|
46
|
+
);
|
|
47
|
+
return [...own, ...Array.flatten(children)];
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const nodeValue = (node: ConfigProvider.Node | undefined): string | undefined =>
|
|
51
|
+
node?._tag === "Value" ? node.value : node?.value;
|
|
52
|
+
|
|
53
|
+
export const readEnvironmentRaw = Effect.fn("Environment.readRaw")(function* (
|
|
54
|
+
appDirectory: string,
|
|
55
|
+
hasDefinition: boolean,
|
|
56
|
+
) {
|
|
57
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
58
|
+
const path = yield* Path.Path;
|
|
59
|
+
const environmentPath = path.join(appDirectory, ".env.ignotum");
|
|
60
|
+
const exists = yield* fileSystem.exists(environmentPath);
|
|
61
|
+
if (!hasDefinition && exists) {
|
|
62
|
+
return yield* EnvironmentConfigurationInvalid.make({
|
|
63
|
+
message: ".env.ignotum exists but server/env.ts does not define its variables.",
|
|
64
|
+
path: environmentPath,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!hasDefinition) {
|
|
68
|
+
return { path: environmentPath, raw: {} } satisfies RawEnvironment;
|
|
69
|
+
}
|
|
70
|
+
if (!exists) {
|
|
71
|
+
return yield* EnvironmentConfigurationInvalid.make({
|
|
72
|
+
message: "server/env.ts requires a project-root .env.ignotum file.",
|
|
73
|
+
path: environmentPath,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const provider = yield* ConfigProvider.fromDotEnv({
|
|
78
|
+
path: environmentPath,
|
|
79
|
+
preserveEmptyStrings: true,
|
|
80
|
+
}).pipe(
|
|
81
|
+
Effect.mapError(() =>
|
|
82
|
+
EnvironmentConfigurationInvalid.make({
|
|
83
|
+
message: "Ignotum could not read the project environment file.",
|
|
84
|
+
path: environmentPath,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
const keys = yield* collectKeys(provider).pipe(
|
|
89
|
+
Effect.mapError(() =>
|
|
90
|
+
EnvironmentConfigurationInvalid.make({
|
|
91
|
+
message: "Ignotum could not inspect the project environment file.",
|
|
92
|
+
path: environmentPath,
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
const entries = yield* Effect.forEach(keys, (key) =>
|
|
97
|
+
provider.load([key]).pipe(
|
|
98
|
+
Effect.map((node) => [key, nodeValue(node)] as const),
|
|
99
|
+
Effect.mapError(() =>
|
|
100
|
+
EnvironmentConfigurationInvalid.make({
|
|
101
|
+
message: `Ignotum could not read environment variable '${key}'.`,
|
|
102
|
+
path: environmentPath,
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
const raw = Object.fromEntries(
|
|
108
|
+
entries.flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const])),
|
|
109
|
+
);
|
|
110
|
+
return { path: environmentPath, raw } satisfies RawEnvironment;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const loadEnvironment = Effect.fn("Environment.load")(function* (
|
|
114
|
+
appDirectory: string,
|
|
115
|
+
definition: DefinedEnv,
|
|
116
|
+
hasDefinition: boolean,
|
|
117
|
+
) {
|
|
118
|
+
const loaded = yield* readEnvironmentRaw(appDirectory, hasDefinition);
|
|
119
|
+
if (!hasDefinition) {
|
|
120
|
+
return {
|
|
121
|
+
definition: emptyEnv,
|
|
122
|
+
raw: {},
|
|
123
|
+
value: Object.freeze({}),
|
|
124
|
+
} satisfies LoadedEnvironment<typeof emptyEnv>;
|
|
125
|
+
}
|
|
126
|
+
const raw = loaded.raw;
|
|
127
|
+
const value = yield* decodeDefinedEnvironment(definition, raw).pipe(
|
|
128
|
+
Effect.mapError((error) =>
|
|
129
|
+
EnvironmentConfigurationInvalid.make({ message: error.message, path: loaded.path }),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
return { definition, raw, value } satisfies LoadedEnvironment;
|
|
133
|
+
});
|
package/src/client/files.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type FileValue,
|
|
13
13
|
} from "@ignotum/contracts/schema/file";
|
|
14
14
|
import { IdGenerator } from "@ignotum/shared/id";
|
|
15
|
-
import { Effect, Predicate } from "effect";
|
|
15
|
+
import { Effect, Predicate, Schema } from "effect";
|
|
16
16
|
|
|
17
17
|
export const Files = {
|
|
18
18
|
url: (file: FileValue): string => {
|
|
@@ -27,13 +27,19 @@ export const Files = {
|
|
|
27
27
|
const formatByMime: Partial<Record<string, FileFormat>> = {};
|
|
28
28
|
for (const format of fileFormats) formatByMime[fileMimeTypes[format]] = format;
|
|
29
29
|
|
|
30
|
+
const MutationObject = Schema.ObjectKeyword.check(
|
|
31
|
+
Schema.makeFilter((value) => Object.prototype.toString.call(value) === "[object Object]", {
|
|
32
|
+
message: "Mutation objects must contain ordinary fields; built-in collections are unsupported.",
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
30
36
|
const collectNativeFiles = (
|
|
31
37
|
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This is the recursive parser for generated mutation arguments and native File values.
|
|
32
38
|
value: unknown,
|
|
33
|
-
files:
|
|
39
|
+
files: Set<File>,
|
|
34
40
|
): void => {
|
|
35
41
|
if (value instanceof File) {
|
|
36
|
-
files.
|
|
42
|
+
files.add(value);
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
if (isFileValue(value) || Predicate.isDate(value)) return;
|
|
@@ -42,7 +48,9 @@ const collectNativeFiles = (
|
|
|
42
48
|
return;
|
|
43
49
|
}
|
|
44
50
|
if (!Predicate.isObject(value)) return;
|
|
45
|
-
for (const child of Object.values(value))
|
|
51
|
+
for (const child of Object.values(Schema.decodeSync(MutationObject)(value))) {
|
|
52
|
+
collectNativeFiles(child, files);
|
|
53
|
+
}
|
|
46
54
|
};
|
|
47
55
|
|
|
48
56
|
const replaceNativeFiles = (
|
|
@@ -72,36 +80,42 @@ export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArg
|
|
|
72
80
|
input: unknown,
|
|
73
81
|
) {
|
|
74
82
|
const ids = yield* IdGenerator;
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const unique = yield* Effect.try(() => {
|
|
84
|
+
const files = new Set<File>();
|
|
85
|
+
collectNativeFiles(input, files);
|
|
86
|
+
if (files.size > fileLimits.filesPerMutation) {
|
|
87
|
+
throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
|
|
88
|
+
}
|
|
89
|
+
const totalBytes = [...files].reduce((total, file) => total + file.size, 0);
|
|
90
|
+
if (totalBytes > fileLimits.mutationBytes) {
|
|
91
|
+
throw new Error(`Mutation uploads may total at most ${fileLimits.mutationBytes} bytes.`);
|
|
92
|
+
}
|
|
93
|
+
return files;
|
|
94
|
+
});
|
|
85
95
|
const replacements = new Map<File, FileValue>();
|
|
86
96
|
const nativeFiles = new Map<FileId, File>();
|
|
87
97
|
for (const file of unique) {
|
|
88
|
-
const format =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
const format = yield* Effect.try(() => {
|
|
99
|
+
const format = formatByMime[file.type.toLowerCase()];
|
|
100
|
+
if (format === undefined) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`File '${file.name}' has unsupported media type '${file.type || "unknown"}'.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (file.size === 0 || file.size > fileLimits.fileBytes) {
|
|
106
|
+
throw new Error(`File '${file.name}' must be between 1 and ${fileLimits.fileBytes} bytes.`);
|
|
107
|
+
}
|
|
108
|
+
if (new TextEncoder().encode(file.name).byteLength > fileLimits.filenameBytes) {
|
|
109
|
+
throw new Error(`File '${file.name}' has a filename that is too long.`);
|
|
110
|
+
}
|
|
111
|
+
return format;
|
|
112
|
+
});
|
|
100
113
|
const id = yield* ids.generate(FileId);
|
|
101
114
|
replacements.set(file, makeFileValue(id, { format, name: file.name, size: file.size }));
|
|
102
115
|
nativeFiles.set(id, file);
|
|
103
116
|
}
|
|
104
|
-
const value =
|
|
117
|
+
const value =
|
|
118
|
+
unique.size === 0 ? input : yield* Effect.try(() => replaceNativeFiles(input, replacements));
|
|
105
119
|
return {
|
|
106
120
|
value,
|
|
107
121
|
nativeFiles,
|
|
@@ -121,6 +135,8 @@ export const applyFileGrants = (
|
|
|
121
135
|
grants: ReadonlyArray<FileGrant>,
|
|
122
136
|
// oxlint-disable-next-line anti-slop/no-unknown-returns -- The traversal preserves the decoded query result shape while attaching private symbols.
|
|
123
137
|
): unknown => {
|
|
138
|
+
if (grants.length === 0) return value;
|
|
139
|
+
|
|
124
140
|
const byId = new Map<FileId, string>();
|
|
125
141
|
for (const grant of grants) {
|
|
126
142
|
if (byId.has(grant.id)) throw new Error("A query file grant ID is duplicated.");
|
package/src/client/hooks.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { BrowserSession, type IdState } from "./id.js";
|
|
2
|
+
import type { ProfileField } from "@ignotum/contracts/id";
|
|
1
3
|
import { Effect, Schema } from "effect";
|
|
2
4
|
import { useCallback, useDebugValue, useEffect, useMemo, useState } from "preact/hooks";
|
|
3
5
|
|
|
@@ -12,6 +14,8 @@ import {
|
|
|
12
14
|
import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
|
|
13
15
|
import { ClientInfrastructureError } from "./errors.js";
|
|
14
16
|
import { isQuerySkip, type QuerySkip } from "./query.js";
|
|
17
|
+
// oxlint-disable-next-line anti-slop-effect/no-service-constructor-imports -- Each mounted pagination hook owns and releases its page observers.
|
|
18
|
+
import { makePageObservers } from "./page-observers.js";
|
|
15
19
|
import { SyncClient, syncClientInternals, syncRuntime } from "./sync.js";
|
|
16
20
|
|
|
17
21
|
// @effect-diagnostics-next-line missingPipeableSignature:off React hooks are not pipeable functions.
|
|
@@ -158,6 +162,18 @@ export function usePaginatedQuery<Args extends object, Item, Failure extends Err
|
|
|
158
162
|
readonly result: QueryResult<PaginatedQueryValue<Item>, Failure> | ClientInfrastructureError;
|
|
159
163
|
}>(() => ({ identity, result: pending() }));
|
|
160
164
|
const loadMore = useCallback(() => setRequestedPages((count) => count + 1), []);
|
|
165
|
+
const pages = useMemo(
|
|
166
|
+
() =>
|
|
167
|
+
makePageObservers(
|
|
168
|
+
() => setRefresh((version) => version + 1),
|
|
169
|
+
(release) => {
|
|
170
|
+
syncRuntime.runFork(release);
|
|
171
|
+
},
|
|
172
|
+
),
|
|
173
|
+
[identity, pageSize],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
useEffect(() => () => pages.release(), [pages]);
|
|
161
177
|
|
|
162
178
|
useDebugValue({ args: input, function: functionPath, result: state.result });
|
|
163
179
|
|
|
@@ -170,12 +186,11 @@ export function usePaginatedQuery<Args extends object, Item, Failure extends Err
|
|
|
170
186
|
if (skipped) return;
|
|
171
187
|
|
|
172
188
|
let active = true;
|
|
173
|
-
const
|
|
189
|
+
const usedPages = new Set<string>();
|
|
174
190
|
|
|
175
191
|
void syncRuntime
|
|
176
192
|
.runPromise(
|
|
177
193
|
Effect.gen(function* () {
|
|
178
|
-
const client = yield* SyncClient;
|
|
179
194
|
const items: Item[] = [];
|
|
180
195
|
let cursor: PaginationOptions["cursor"] = null;
|
|
181
196
|
|
|
@@ -184,18 +199,9 @@ export function usePaginatedQuery<Args extends object, Item, Failure extends Err
|
|
|
184
199
|
...input,
|
|
185
200
|
pagination: { cursor, pageSize },
|
|
186
201
|
});
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const releaseSubscription = observer.subscribe(() => {
|
|
193
|
-
if (active) setRefresh((version) => version + 1);
|
|
194
|
-
});
|
|
195
|
-
releases.push(() => {
|
|
196
|
-
releaseSubscription();
|
|
197
|
-
syncRuntime.runFork(observer.release);
|
|
198
|
-
});
|
|
202
|
+
usedPages.add(syncClientInternals.queryIdentity(functionPath, pageArgs));
|
|
203
|
+
const observer = yield* pages.observe(functionPath, pageArgs);
|
|
204
|
+
if (!active || observer === undefined) return;
|
|
199
205
|
|
|
200
206
|
const snapshot = observer.getSnapshot();
|
|
201
207
|
if (Schema.is(ClientInfrastructureError)(snapshot)) {
|
|
@@ -240,7 +246,13 @@ export function usePaginatedQuery<Args extends object, Item, Failure extends Err
|
|
|
240
246
|
identity,
|
|
241
247
|
result: RuntimeResult.succeed({ items, loadMore, status: "CanLoadMore" }),
|
|
242
248
|
});
|
|
243
|
-
})
|
|
249
|
+
}).pipe(
|
|
250
|
+
Effect.ensuring(
|
|
251
|
+
Effect.sync(() => {
|
|
252
|
+
if (active) pages.retain(usedPages);
|
|
253
|
+
}),
|
|
254
|
+
),
|
|
255
|
+
),
|
|
244
256
|
)
|
|
245
257
|
.catch((error) => {
|
|
246
258
|
if (active && Schema.is(ClientInfrastructureError)(error)) {
|
|
@@ -250,9 +262,8 @@ export function usePaginatedQuery<Args extends object, Item, Failure extends Err
|
|
|
250
262
|
|
|
251
263
|
return () => {
|
|
252
264
|
active = false;
|
|
253
|
-
for (const release of releases) release();
|
|
254
265
|
};
|
|
255
|
-
}, [functionPath, identity, loadMore, pageSize, refresh, requestedPages, skipped]);
|
|
266
|
+
}, [functionPath, identity, loadMore, pageSize, pages, refresh, requestedPages, skipped]);
|
|
256
267
|
|
|
257
268
|
if (skipped || state.identity !== identity) return pending();
|
|
258
269
|
const result = state.result;
|
|
@@ -288,3 +299,69 @@ export const useMutation = <Args extends object | void, Success, Failure extends
|
|
|
288
299
|
// both call shapes normalize omitted arguments to the empty JSON object.
|
|
289
300
|
return mutate as Mutation<Args, Success, Failure>;
|
|
290
301
|
}, [reference]);
|
|
302
|
+
|
|
303
|
+
export type IdOptions = {
|
|
304
|
+
readonly profile?: ReadonlyArray<ProfileField>;
|
|
305
|
+
readonly returnTo?: string;
|
|
306
|
+
};
|
|
307
|
+
export type IdResult = IdState & {
|
|
308
|
+
readonly signIn: (options?: IdOptions) => Promise<void>;
|
|
309
|
+
readonly requestProfile: (
|
|
310
|
+
fields: ReadonlyArray<ProfileField>,
|
|
311
|
+
options?: { readonly returnTo?: string },
|
|
312
|
+
) => Promise<void>;
|
|
313
|
+
readonly signOut: () => Promise<void>;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export function useId(): IdResult {
|
|
317
|
+
const [state, setState] = useState<IdState>({ status: "pending" });
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
let active = true;
|
|
320
|
+
let unsubscribe: (() => void) | undefined;
|
|
321
|
+
void syncRuntime.runPromise(BrowserSession).then((session) => {
|
|
322
|
+
if (!active) return;
|
|
323
|
+
const update = () => setState(session.snapshot());
|
|
324
|
+
unsubscribe = session.subscribe(update);
|
|
325
|
+
update();
|
|
326
|
+
});
|
|
327
|
+
return () => {
|
|
328
|
+
active = false;
|
|
329
|
+
unsubscribe?.();
|
|
330
|
+
};
|
|
331
|
+
}, []);
|
|
332
|
+
const actions = useMemo(
|
|
333
|
+
() => ({
|
|
334
|
+
signIn: (options?: IdOptions) =>
|
|
335
|
+
syncRuntime.runPromise(
|
|
336
|
+
Effect.gen(function* () {
|
|
337
|
+
return yield* (yield* BrowserSession).start(
|
|
338
|
+
"signIn",
|
|
339
|
+
options?.profile ?? [],
|
|
340
|
+
options?.returnTo,
|
|
341
|
+
);
|
|
342
|
+
}),
|
|
343
|
+
),
|
|
344
|
+
requestProfile: (
|
|
345
|
+
fields: ReadonlyArray<ProfileField>,
|
|
346
|
+
options?: { readonly returnTo?: string },
|
|
347
|
+
) =>
|
|
348
|
+
syncRuntime.runPromise(
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
return yield* (yield* BrowserSession).start(
|
|
351
|
+
"requestProfile",
|
|
352
|
+
fields,
|
|
353
|
+
options?.returnTo,
|
|
354
|
+
);
|
|
355
|
+
}),
|
|
356
|
+
),
|
|
357
|
+
signOut: () =>
|
|
358
|
+
syncRuntime.runPromise(
|
|
359
|
+
Effect.gen(function* () {
|
|
360
|
+
return yield* (yield* BrowserSession).signOut;
|
|
361
|
+
}),
|
|
362
|
+
),
|
|
363
|
+
}),
|
|
364
|
+
[],
|
|
365
|
+
);
|
|
366
|
+
return { ...state, ...actions };
|
|
367
|
+
}
|
package/src/client/id.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Schema, Semaphore } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
FetchHttpClient,
|
|
4
|
+
HttpClient,
|
|
5
|
+
HttpClientRequest,
|
|
6
|
+
HttpClientResponse,
|
|
7
|
+
} from "effect/unstable/http";
|
|
8
|
+
import {
|
|
9
|
+
DevelopmentSelection,
|
|
10
|
+
developmentSelectionKey,
|
|
11
|
+
SessionOutcome,
|
|
12
|
+
SessionRedirect,
|
|
13
|
+
SessionRequest,
|
|
14
|
+
SessionResponse,
|
|
15
|
+
sessionPath,
|
|
16
|
+
sessionRequestsPath,
|
|
17
|
+
type ProfileField,
|
|
18
|
+
type SessionState,
|
|
19
|
+
type User,
|
|
20
|
+
} from "@ignotum/contracts/id";
|
|
21
|
+
import { IdGenerator } from "@ignotum/shared/id";
|
|
22
|
+
import { SessionEpoch } from "@ignotum/contracts/runtime/id";
|
|
23
|
+
|
|
24
|
+
export class IdRequestError extends Schema.TaggedError<IdRequestError>()("IdRequestError", {
|
|
25
|
+
message: Schema.String,
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
export type IdState =
|
|
29
|
+
| { readonly status: "pending"; readonly outcome?: SessionOutcome }
|
|
30
|
+
| { readonly status: "error"; readonly error: string; readonly outcome?: SessionOutcome }
|
|
31
|
+
| { readonly status: "signedOut"; readonly outcome?: SessionOutcome }
|
|
32
|
+
| { readonly status: "signedIn"; readonly user: User; readonly outcome?: SessionOutcome };
|
|
33
|
+
|
|
34
|
+
const outcomeKey = "ignotum.id.outcome";
|
|
35
|
+
const selectionJson = Schema.fromJsonString(DevelopmentSelection);
|
|
36
|
+
|
|
37
|
+
export class BrowserSession extends Context.Service<
|
|
38
|
+
BrowserSession,
|
|
39
|
+
{
|
|
40
|
+
readonly snapshot: () => IdState;
|
|
41
|
+
readonly current: () => SessionState | undefined;
|
|
42
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
43
|
+
readonly bootstrap: Effect.Effect<SessionState, IdRequestError>;
|
|
44
|
+
readonly accept: (session: SessionState, serverTime: number) => void;
|
|
45
|
+
readonly suspend: (error?: string) => void;
|
|
46
|
+
readonly socketParameters: () => string;
|
|
47
|
+
readonly start: (
|
|
48
|
+
intent: "signIn" | "requestProfile",
|
|
49
|
+
profile: ReadonlyArray<ProfileField>,
|
|
50
|
+
returnTo?: string,
|
|
51
|
+
) => Effect.Effect<void, IdRequestError>;
|
|
52
|
+
readonly signOut: Effect.Effect<void, IdRequestError>;
|
|
53
|
+
}
|
|
54
|
+
>()("ignotum/client/id/BrowserSession") {
|
|
55
|
+
static readonly layer = Layer.effect(
|
|
56
|
+
BrowserSession,
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
|
|
59
|
+
const ids = yield* IdGenerator;
|
|
60
|
+
const bootstrapLock = yield* Semaphore.make(1);
|
|
61
|
+
const listeners = new Set<() => void>();
|
|
62
|
+
let state: IdState = { status: "pending" };
|
|
63
|
+
let current: SessionState | undefined;
|
|
64
|
+
let generation = 0;
|
|
65
|
+
let deadline = Infinity;
|
|
66
|
+
let development = false;
|
|
67
|
+
let selection: DevelopmentSelection | undefined;
|
|
68
|
+
let expiry: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
const notify = () => {
|
|
70
|
+
for (const listener of listeners) listener();
|
|
71
|
+
};
|
|
72
|
+
const suspend = (error?: string) => {
|
|
73
|
+
generation += 1;
|
|
74
|
+
deadline = Infinity;
|
|
75
|
+
if (expiry !== undefined) clearTimeout(expiry);
|
|
76
|
+
current = undefined;
|
|
77
|
+
state =
|
|
78
|
+
error === undefined
|
|
79
|
+
? { status: "pending", outcome: state.outcome }
|
|
80
|
+
: { status: "error", error, outcome: state.outcome };
|
|
81
|
+
notify();
|
|
82
|
+
};
|
|
83
|
+
const accept = (session: SessionState, serverTime: number) => {
|
|
84
|
+
generation += 1;
|
|
85
|
+
if (expiry !== undefined) clearTimeout(expiry);
|
|
86
|
+
current = session;
|
|
87
|
+
state =
|
|
88
|
+
session.user === null
|
|
89
|
+
? { status: "signedOut", outcome: state.outcome }
|
|
90
|
+
: { status: "signedIn", user: session.user, outcome: state.outcome };
|
|
91
|
+
const delay = Math.max(0, session.validUntil - serverTime);
|
|
92
|
+
deadline = performance.now() + delay;
|
|
93
|
+
if (delay < 2_147_483_647) expiry = setTimeout(() => suspend(), delay);
|
|
94
|
+
notify();
|
|
95
|
+
};
|
|
96
|
+
const checkExpiry = () => {
|
|
97
|
+
if (current !== undefined && performance.now() >= deadline) suspend();
|
|
98
|
+
};
|
|
99
|
+
globalThis.addEventListener?.("focus", checkExpiry);
|
|
100
|
+
globalThis.document?.addEventListener("visibilitychange", checkExpiry);
|
|
101
|
+
yield* Effect.addFinalizer(() =>
|
|
102
|
+
Effect.sync(() => {
|
|
103
|
+
if (expiry !== undefined) clearTimeout(expiry);
|
|
104
|
+
listeners.clear();
|
|
105
|
+
globalThis.removeEventListener?.("focus", checkExpiry);
|
|
106
|
+
globalThis.document?.removeEventListener("visibilitychange", checkExpiry);
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const readOutcome = () => {
|
|
111
|
+
const raw = globalThis.sessionStorage.getItem(outcomeKey);
|
|
112
|
+
if (raw !== null) {
|
|
113
|
+
const decoded = Schema.decodeUnknownOption(SessionOutcome)(raw);
|
|
114
|
+
if (Option.isSome(decoded)) state = { ...state, outcome: decoded.value };
|
|
115
|
+
globalThis.sessionStorage.removeItem(outcomeKey);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const parameters = () => {
|
|
119
|
+
if (!development || selection === undefined) return "";
|
|
120
|
+
const params = new URLSearchParams({ epoch: selection.epoch });
|
|
121
|
+
if (selection.username !== null) params.set("username", selection.username);
|
|
122
|
+
return `?${params}`;
|
|
123
|
+
};
|
|
124
|
+
const readSession = Effect.fn("BrowserSession.read")(function* () {
|
|
125
|
+
return yield* client
|
|
126
|
+
.get(`${sessionPath}${parameters()}`, { headers: { "x-ignotum-request": "1" } })
|
|
127
|
+
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(SessionResponse)));
|
|
128
|
+
});
|
|
129
|
+
let bootstrapGeneration = 0;
|
|
130
|
+
const bootstrap = bootstrapLock
|
|
131
|
+
.withPermits(1)(
|
|
132
|
+
Effect.gen(function* () {
|
|
133
|
+
const admittedGeneration = generation;
|
|
134
|
+
bootstrapGeneration = admittedGeneration;
|
|
135
|
+
const startedAt = performance.now();
|
|
136
|
+
let session = yield* readSession();
|
|
137
|
+
development = session.development === true;
|
|
138
|
+
if (development && selection === undefined) {
|
|
139
|
+
const saved = globalThis.sessionStorage.getItem(developmentSelectionKey);
|
|
140
|
+
const decoded =
|
|
141
|
+
saved === null
|
|
142
|
+
? Option.none<DevelopmentSelection>()
|
|
143
|
+
: Schema.decodeOption(selectionJson)(saved);
|
|
144
|
+
selection = Option.getOrElse(decoded, () => ({
|
|
145
|
+
formatVersion: 1 as const,
|
|
146
|
+
username: null,
|
|
147
|
+
epoch: "",
|
|
148
|
+
}));
|
|
149
|
+
if (selection.epoch === "")
|
|
150
|
+
selection = { ...selection, epoch: yield* ids.generate(SessionEpoch) };
|
|
151
|
+
globalThis.sessionStorage.setItem(
|
|
152
|
+
developmentSelectionKey,
|
|
153
|
+
Schema.encodeSync(selectionJson)(selection),
|
|
154
|
+
);
|
|
155
|
+
session = yield* readSession();
|
|
156
|
+
}
|
|
157
|
+
if (generation !== admittedGeneration) {
|
|
158
|
+
if (current !== undefined) return current;
|
|
159
|
+
return yield* IdRequestError.make({
|
|
160
|
+
message: "Your ID session changed while loading.",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
readOutcome();
|
|
164
|
+
if (session.outcome !== undefined) state = { ...state, outcome: session.outcome };
|
|
165
|
+
accept(session, session.serverTime + performance.now() - startedAt);
|
|
166
|
+
return session;
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
.pipe(
|
|
170
|
+
Effect.mapError(() =>
|
|
171
|
+
IdRequestError.make({ message: "Could not load your ID session." }),
|
|
172
|
+
),
|
|
173
|
+
Effect.tapError((error) =>
|
|
174
|
+
Effect.sync(() => {
|
|
175
|
+
if (generation === bootstrapGeneration) suspend(error.message);
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const start = Effect.fn("BrowserSession.start")(function* (
|
|
181
|
+
intent: "signIn" | "requestProfile",
|
|
182
|
+
profile: ReadonlyArray<ProfileField>,
|
|
183
|
+
returnTo?: string,
|
|
184
|
+
) {
|
|
185
|
+
const session = current ?? (yield* bootstrap);
|
|
186
|
+
if (intent === "requestProfile" && session.user === null)
|
|
187
|
+
return yield* IdRequestError.make({
|
|
188
|
+
message: "Sign in before requesting profile information.",
|
|
189
|
+
});
|
|
190
|
+
const request = yield* Schema.decodeUnknownEffect(SessionRequest)({
|
|
191
|
+
intent,
|
|
192
|
+
profile,
|
|
193
|
+
sessionEpoch: session.sessionEpoch,
|
|
194
|
+
returnTo:
|
|
195
|
+
returnTo ??
|
|
196
|
+
`${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`,
|
|
197
|
+
}).pipe(
|
|
198
|
+
Effect.mapError(() => IdRequestError.make({ message: "The ID request is invalid." })),
|
|
199
|
+
);
|
|
200
|
+
const redirect = yield* HttpClientRequest.post(sessionRequestsPath).pipe(
|
|
201
|
+
HttpClientRequest.setHeader("x-ignotum-request", "1"),
|
|
202
|
+
HttpClientRequest.bodyJsonUnsafe(request),
|
|
203
|
+
client.execute,
|
|
204
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(SessionRedirect)),
|
|
205
|
+
Effect.mapError(() =>
|
|
206
|
+
IdRequestError.make({ message: "Could not start the ID request." }),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
if (current?.sessionEpoch !== session.sessionEpoch)
|
|
210
|
+
return yield* IdRequestError.make({ message: "Your ID session changed. Try again." });
|
|
211
|
+
globalThis.location.assign(redirect.redirectUrl);
|
|
212
|
+
});
|
|
213
|
+
const signOut = Effect.gen(function* () {
|
|
214
|
+
const session = current ?? (yield* bootstrap);
|
|
215
|
+
yield* HttpClientRequest.delete(sessionPath).pipe(
|
|
216
|
+
HttpClientRequest.setHeader("x-ignotum-request", "1"),
|
|
217
|
+
HttpClientRequest.bodyJsonUnsafe({ sessionEpoch: session.sessionEpoch }),
|
|
218
|
+
client.execute,
|
|
219
|
+
Effect.mapError(() => IdRequestError.make({ message: "Could not sign out." })),
|
|
220
|
+
);
|
|
221
|
+
suspend();
|
|
222
|
+
if (development) {
|
|
223
|
+
selection = {
|
|
224
|
+
formatVersion: 1,
|
|
225
|
+
username: null,
|
|
226
|
+
epoch: yield* ids.generate(SessionEpoch),
|
|
227
|
+
};
|
|
228
|
+
globalThis.sessionStorage.setItem(
|
|
229
|
+
developmentSelectionKey,
|
|
230
|
+
Schema.encodeSync(selectionJson)(selection),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
globalThis.location.reload();
|
|
234
|
+
});
|
|
235
|
+
return BrowserSession.of({
|
|
236
|
+
snapshot: () => {
|
|
237
|
+
checkExpiry();
|
|
238
|
+
return state;
|
|
239
|
+
},
|
|
240
|
+
current: () => {
|
|
241
|
+
checkExpiry();
|
|
242
|
+
return current;
|
|
243
|
+
},
|
|
244
|
+
subscribe: (listener) => {
|
|
245
|
+
listeners.add(listener);
|
|
246
|
+
return () => {
|
|
247
|
+
listeners.delete(listener);
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
accept,
|
|
251
|
+
suspend,
|
|
252
|
+
bootstrap,
|
|
253
|
+
start,
|
|
254
|
+
signOut,
|
|
255
|
+
socketParameters: parameters,
|
|
256
|
+
});
|
|
257
|
+
}),
|
|
258
|
+
).pipe(Layer.provide(FetchHttpClient.layer));
|
|
259
|
+
}
|