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.
Files changed (61) hide show
  1. package/README.md +8 -3
  2. package/dist/cli/bin.mjs +1372 -492
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DzcR7spt.js → api-CAgKDij7.js} +38 -2
  5. package/dist/runtime/api-CAgKDij7.js.map +1 -0
  6. package/dist/runtime/{api-DR_8vfKg.d.ts → api-Cv3hMbzo.d.ts} +4 -3
  7. package/dist/runtime/client.d.ts +39 -8
  8. package/dist/runtime/client.js +505 -131
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +154 -0
  11. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +1 -0
  12. package/dist/runtime/id-Bt9XWRGL.js +423 -0
  13. package/dist/runtime/id-Bt9XWRGL.js.map +1 -0
  14. package/dist/runtime/{id-Cs82tq9Q-hmYFyqTa.d.ts → id-BzFHf3Wo-DGPCjgrf.d.ts} +13 -13
  15. package/dist/runtime/id-Dz0apuB3.d.ts +1 -0
  16. package/dist/runtime/{index-2q9FwJud.d.ts → index-D54flWtH.d.ts} +136 -76
  17. package/dist/runtime/internal/api.d.ts +1 -1
  18. package/dist/runtime/internal/api.js +1 -1
  19. package/dist/runtime/internal/host.d.ts +21 -17
  20. package/dist/runtime/internal/host.js +22 -8
  21. package/dist/runtime/internal/host.js.map +1 -1
  22. package/dist/runtime/internal/server.d.ts +1 -1
  23. package/dist/runtime/internal/server.js +1 -1
  24. package/dist/runtime/internal/types.d.ts +1 -1
  25. package/dist/runtime/internal/types.js +1 -1
  26. package/dist/runtime/{pagination-Bt3l7QaC-BYEGmLBE.d.ts → pagination-DnKg3dkI-r5ZUxBBx.d.ts} +34 -6
  27. package/dist/runtime/pagination-Dz0apuB3.d.ts +1 -0
  28. package/dist/runtime/result-DKAA4gpS.d.ts +1 -0
  29. package/dist/runtime/{schema-ERFjT8-m.js → schema-1Zs03-iS.js} +135 -17
  30. package/dist/runtime/schema-1Zs03-iS.js.map +1 -0
  31. package/dist/runtime/server.d.ts +9 -3
  32. package/dist/runtime/server.js +5 -3
  33. package/dist/runtime/server.js.map +1 -1
  34. package/dist/runtime/{sync-a2EdGSdY.d.ts → sync-Bs8J3fIr.d.ts} +2 -2
  35. package/package.json +4 -4
  36. package/src/cli/agent-files.ts +8 -0
  37. package/src/cli/auth-client.ts +1 -1
  38. package/src/cli/bin.ts +13 -3
  39. package/src/cli/build/server.ts +102 -13
  40. package/src/cli/codegen.ts +7 -4
  41. package/src/cli/control-client.ts +30 -8
  42. package/src/cli/deploy.ts +7 -2
  43. package/src/cli/environment.ts +133 -0
  44. package/src/client/files.ts +43 -27
  45. package/src/client/hooks.ts +94 -17
  46. package/src/client/id.ts +259 -0
  47. package/src/client/index.ts +5 -2
  48. package/src/client/page-observers.ts +77 -0
  49. package/src/client/sync.ts +214 -69
  50. package/src/dev-runtime/functions.ts +232 -86
  51. package/src/dev-runtime/id.ts +174 -2
  52. package/src/dev-runtime/query-cache.ts +105 -0
  53. package/src/dev-runtime/sync.ts +187 -26
  54. package/src/server/index.ts +13 -1
  55. package/dist/runtime/api-DzcR7spt.js.map +0 -1
  56. package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js +0 -311
  57. package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js.map +0 -1
  58. package/dist/runtime/file-C1abuMgd.js +0 -173
  59. package/dist/runtime/file-C1abuMgd.js.map +0 -1
  60. package/dist/runtime/pagination-CFJ3xlAt.d.ts +0 -1
  61. 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
+ });
@@ -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: Array<File>,
39
+ files: Set<File>,
34
40
  ): void => {
35
41
  if (value instanceof File) {
36
- files.push(value);
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)) collectNativeFiles(child, files);
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 native: File[] = [];
76
- collectNativeFiles(input, native);
77
- const unique = new Set(native);
78
- if (unique.size > fileLimits.filesPerMutation) {
79
- throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
80
- }
81
- const totalBytes = [...unique].reduce((total, file) => total + file.size, 0);
82
- if (totalBytes > fileLimits.mutationBytes) {
83
- throw new Error(`Mutation uploads may total at most ${fileLimits.mutationBytes} bytes.`);
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 = formatByMime[file.type.toLowerCase()];
89
- if (format === undefined) {
90
- throw new Error(
91
- `File '${file.name}' has unsupported media type '${file.type || "unknown"}'.`,
92
- );
93
- }
94
- if (file.size === 0 || file.size > fileLimits.fileBytes) {
95
- throw new Error(`File '${file.name}' must be between 1 and ${fileLimits.fileBytes} bytes.`);
96
- }
97
- if (new TextEncoder().encode(file.name).byteLength > fileLimits.filenameBytes) {
98
- throw new Error(`File '${file.name}' has a filename that is too long.`);
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 = replaceNativeFiles(input, replacements);
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.");
@@ -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 releases: Array<() => void> = [];
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
- const observer = yield* client.observe(functionPath, pageArgs);
188
- if (!active) {
189
- yield* observer.release;
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
+ }
@@ -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
+ }