@aventara/client 1.0.0-rc.1 → 1.0.0-rc.11

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 (62) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +147 -11
  3. package/dist/cli/command.parser.d.ts +4 -3
  4. package/dist/cli/command.parser.js +3 -3
  5. package/dist/cli/generation-failure.renderer.d.ts +2 -1
  6. package/dist/cli/generation-success.renderer.d.ts +2 -1
  7. package/dist/cli/step.reporter.d.ts +2 -1
  8. package/dist/cli/terminal.prompter.d.ts +2 -1
  9. package/dist/config/client-config.interface.d.ts +17 -0
  10. package/dist/config/config.loader.d.ts +8 -3
  11. package/dist/config/config.loader.js +20 -2
  12. package/dist/config/config.resolver.d.ts +10 -3
  13. package/dist/config/config.resolver.js +28 -0
  14. package/dist/config/env.cascade.d.ts +2 -6
  15. package/dist/config/env.cascade.js +3 -3
  16. package/dist/config/module-style.resolver.d.ts +3 -6
  17. package/dist/config/module-style.resolver.js +2 -2
  18. package/dist/config/tsconfig.locator.d.ts +2 -1
  19. package/dist/config/tsconfig.locator.js +14 -2
  20. package/dist/contract/contract.acceptance.d.ts +0 -1
  21. package/dist/contract/contract.acceptance.js +1 -1
  22. package/dist/contract/contract.fetcher.d.ts +2 -1
  23. package/dist/contract/contract.loader.d.ts +3 -2
  24. package/dist/emit/access.emitter.d.ts +12 -0
  25. package/dist/emit/access.emitter.js +263 -0
  26. package/dist/emit/banner.emitter.d.ts +6 -1
  27. package/dist/emit/banner.emitter.js +12 -8
  28. package/dist/emit/cached-read.emitter.d.ts +3 -0
  29. package/dist/emit/cached-read.emitter.js +158 -0
  30. package/dist/emit/client-surface.emitter.js +130 -13
  31. package/dist/emit/client-tree.emitter.d.ts +9 -2
  32. package/dist/emit/client-tree.emitter.js +51 -15
  33. package/dist/emit/derivation.emitter.d.ts +3 -3
  34. package/dist/emit/derivation.emitter.js +6 -3
  35. package/dist/emit/descriptor.emitter.d.ts +2 -0
  36. package/dist/emit/descriptor.emitter.js +28 -1
  37. package/dist/emit/emitted-tree.interface.d.ts +1 -1
  38. package/dist/emit/field-check.emitter.d.ts +2 -1
  39. package/dist/emit/field-check.emitter.js +2 -13
  40. package/dist/emit/module-specifier.scanner.d.ts +2 -1
  41. package/dist/emit/name.deriver.d.ts +2 -3
  42. package/dist/emit/name.deriver.js +6 -1
  43. package/dist/emit/named-type.emitter.js +12 -2
  44. package/dist/emit/operation-set.emitter.d.ts +3 -0
  45. package/dist/emit/operation-set.emitter.js +604 -0
  46. package/dist/emit/runtime.emitter.d.ts +5 -4
  47. package/dist/emit/runtime.emitter.js +484 -42
  48. package/dist/emit/scalar.codec.js +223 -6
  49. package/dist/emit/transaction.emitter.js +7 -3
  50. package/dist/generate.d.ts +3 -2
  51. package/dist/generate.js +1 -1
  52. package/dist/hydration/hydration-tag.format.d.ts +11 -0
  53. package/dist/hydration/hydration-tag.format.js +6 -0
  54. package/dist/init/client-init.planner.d.ts +3 -2
  55. package/dist/init/client-init.questions.d.ts +1 -12
  56. package/dist/init/client-init.questions.js +2 -2
  57. package/dist/internal.d.ts +25 -0
  58. package/dist/internal.js +11 -0
  59. package/dist/output/output.validator.d.ts +3 -4
  60. package/dist/output/output.validator.js +2 -2
  61. package/dist/output/output.writer.d.ts +3 -2
  62. package/package.json +9 -5
@@ -9,7 +9,15 @@ export const CLIENT_CONFIG_FILE_EXTENSIONS = [
9
9
  ".mts",
10
10
  ".cts",
11
11
  ];
12
- export const CLIENT_CONFIG_BASENAME = "framework.client";
12
+ export const CLIENT_CONFIG_FILE_EXTENSIONS_AS_LISTED = [
13
+ ".ts",
14
+ ".mts",
15
+ ".cts",
16
+ ".js",
17
+ ".mjs",
18
+ ".cjs",
19
+ ];
20
+ const CLIENT_CONFIG_BASENAME = "framework.client";
13
21
  export const CLIENT_CONFIG_FILE = `${CLIENT_CONFIG_BASENAME}.ts`;
14
22
  export function clientConfigFilesIn(directory) {
15
23
  return CLIENT_CONFIG_FILE_EXTENSIONS.map((extension) => `${CLIENT_CONFIG_BASENAME}${extension}`).filter((name) => statSync(path.resolve(directory, name), {
@@ -27,7 +35,8 @@ export function findClientConfigFile(directory) {
27
35
  export async function loadClientConfigFile(directory) {
28
36
  const name = findClientConfigFile(directory);
29
37
  if (name === undefined) {
30
- throw new AvClientConfigError(`no ${CLIENT_CONFIG_BASENAME}.{${CLIENT_CONFIG_FILE_EXTENSIONS.map((extension) => extension.slice(1)).join(",")}} in ${path.resolve(directory)}; create ${CLIENT_CONFIG_FILE} with ` +
38
+ const [first, ...others] = CLIENT_CONFIG_FILE_EXTENSIONS_AS_LISTED;
39
+ throw new AvClientConfigError(`no ${CLIENT_CONFIG_BASENAME}${first}, ${others.slice(0, -1).join(", ")} or ${others.at(-1)} in ${path.resolve(directory)}; create ${CLIENT_CONFIG_FILE} with ` +
31
40
  "`export default defineClientConfig({ entrypoint, generateAt })` (or run `avclient init`) and run the generator from its directory.");
32
41
  }
33
42
  const file = path.resolve(directory, name);
@@ -66,10 +75,19 @@ function clientConfigOf(file, config) {
66
75
  if (tsconfigFile !== undefined && typeof tsconfigFile !== "string") {
67
76
  throw new AvClientConfigError(`${file}'s tsconfigFile must be a string, a path relative to ${path.dirname(file)}.`);
68
77
  }
78
+ const accessOrigins = members.accessOrigins;
79
+ if (accessOrigins !== undefined && !Array.isArray(accessOrigins)) {
80
+ throw new AvClientConfigError(`${file}'s accessOrigins must be a list of origins ("https://auth.example.com"), each a string or env("NAME").`);
81
+ }
69
82
  return {
70
83
  entrypoint: configValueOf(file, "entrypoint", members.entrypoint),
71
84
  generateAt: configValueOf(file, "generateAt", members.generateAt),
72
85
  ...(tsconfigFile === undefined ? {} : { tsconfigFile }),
86
+ ...(accessOrigins === undefined
87
+ ? {}
88
+ : {
89
+ accessOrigins: accessOrigins.map((origin) => configValueOf(file, "accessOrigins", origin)),
90
+ }),
73
91
  };
74
92
  }
75
93
  function configValueOf(file, member, value) {
@@ -1,4 +1,4 @@
1
- import type { AvClientConfigInput, AvEnvReference, ClientEntrypoint, ResolvedClientConfig } from "./client-config.interface.js";
1
+ import type { AvClientConfigInput, AvEnvReference, ClientEntrypoint, EmittedEntrypoint, ResolvedClientConfig, SameOriginEntrypoint } from "./client-config.interface.js";
2
2
  import type { EnvCascadeResolution } from "./env.cascade.js";
3
3
  /** Gives a `framework.client.ts` its type: returns `config` unchanged. */
4
4
  export declare function defineClientConfig(config: AvClientConfigInput): AvClientConfigInput;
@@ -11,7 +11,7 @@ export declare function env(name: string): AvEnvReference;
11
11
  export declare class AvClientConfigError extends Error {
12
12
  readonly name = "AvClientConfigError";
13
13
  }
14
- export interface ClientConfigResolutionInput {
14
+ interface ClientConfigResolutionInput {
15
15
  readonly config: AvClientConfigInput;
16
16
  readonly cascade: EnvCascadeResolution;
17
17
  /** The directory holding the config file; `generateAt` resolves against it. */
@@ -26,4 +26,11 @@ export declare function resolveClientConfig(input: ClientConfigResolutionInput):
26
26
  * @throws AvClientConfigError in one sentence that does not echo the value.
27
27
  */
28
28
  export declare function resolveEntrypoint(value: string): ClientEntrypoint;
29
- export declare function entrypointHref(entrypoint: ClientEntrypoint): string;
29
+ /**
30
+ * The same-origin entrypoint of a framework mounted at `api`.
31
+ *
32
+ * @throws AvClientConfigError when `api` is not a mount path.
33
+ */
34
+ export declare function sameOriginEntrypoint(api: string): SameOriginEntrypoint;
35
+ export declare function entrypointHref(entrypoint: EmittedEntrypoint): string;
36
+ export {};
@@ -24,9 +24,27 @@ export function resolveClientConfig(input) {
24
24
  : {
25
25
  tsconfigFile: path.resolve(input.configDirectory, tsconfigFile),
26
26
  }),
27
+ accessOrigins: (input.config.accessOrigins ?? []).map((origin) => resolveAccessOrigin(resolveValue("accessOrigins", origin, input.cascade))),
27
28
  mode: input.cascade.mode,
28
29
  };
29
30
  }
31
+ function resolveAccessOrigin(value) {
32
+ let url;
33
+ try {
34
+ url = new URL(value);
35
+ }
36
+ catch {
37
+ url = undefined;
38
+ }
39
+ if (url === undefined ||
40
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
41
+ url.username !== "" ||
42
+ url.password !== "" ||
43
+ url.origin !== value.replace(/\/$/u, "").toLowerCase()) {
44
+ throw new AvClientConfigError('accessOrigins holds a value that is no http(s) origin; each must be scheme, host and port alone ("https://auth.example.com").');
45
+ }
46
+ return url.origin;
47
+ }
30
48
  function resolveValue(field, value, cascade) {
31
49
  if (typeof value === "string") {
32
50
  if (value === "")
@@ -77,7 +95,17 @@ export function resolveEntrypoint(value) {
77
95
  function writtenMountPath(value) {
78
96
  return value.replace(/^[A-Za-z][A-Za-z0-9+.-]*:[/\\]*[^/\\?#]*/u, "");
79
97
  }
98
+ export function sameOriginEntrypoint(api) {
99
+ const normalized = AvProtocol.normalizeEntrypoint(api);
100
+ if (!normalized.ok) {
101
+ throw new AvClientConfigError(`api ${normalized.message}.`);
102
+ }
103
+ return { path: normalized.path };
104
+ }
80
105
  export function entrypointHref(entrypoint) {
106
+ if (entrypoint.deployment === undefined) {
107
+ return entrypoint.path;
108
+ }
81
109
  const url = new URL(entrypoint.deployment.href);
82
110
  url.pathname = entrypoint.path === "" ? "/" : entrypoint.path;
83
111
  return url.href.replace(/\/$/, "");
@@ -2,10 +2,8 @@
2
2
  export type EnvRecord = Readonly<Record<string, string>>;
3
3
  /** The process environment as Node types it: values may be `undefined`. */
4
4
  export type ProcessEnvInput = Readonly<Record<string, string | undefined>>;
5
- /** The mode used when neither an explicit mode nor `NODE_ENV` supplies one. */
6
- export declare const DEFAULT_ENV_MODE = "development";
7
5
  export type EnvFileReader = (filePath: string) => string | undefined;
8
- export interface EnvCascadeInput {
6
+ interface EnvCascadeInput {
9
7
  /** The directory the five candidates are looked up in. */
10
8
  readonly directory: string;
11
9
  /** A snapshot of the process environment. Never mutated. */
@@ -31,10 +29,7 @@ export declare class AvEnvFileError extends Error {
31
29
  }
32
30
  /** The four file candidates for a mode, highest precedence first. */
33
31
  export declare function envCascadeCandidates(mode: string): readonly string[];
34
- export declare function resolveEnvMode(processEnv: ProcessEnvInput, explicit?: string): string;
35
32
  export declare function resolveEnvCascade(input: EnvCascadeInput): EnvCascadeResolution;
36
- /** Reads a candidate from disk; `ENOENT` alone means "not there". */
37
- export declare function readEnvFileFromDisk(filePath: string): string | undefined;
38
33
  /**
39
34
  * Parses one file's content. A file whose content holds a line the parser would
40
35
  * silently discard is malformed: the user wrote something they expect to take
@@ -50,3 +45,4 @@ export declare function parseEnvFile(filePath: string, content: string): Record<
50
45
  * spanning lines — or, when there is none, to the end of its own line.
51
46
  */
52
47
  export declare function firstDiscardedLine(content: string): number | undefined;
48
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseEnv } from "node:util";
4
- export const DEFAULT_ENV_MODE = "development";
4
+ const DEFAULT_ENV_MODE = "development";
5
5
  export class AvEnvFileError extends Error {
6
6
  filePath;
7
7
  reason;
@@ -15,7 +15,7 @@ export class AvEnvFileError extends Error {
15
15
  export function envCascadeCandidates(mode) {
16
16
  return [`.env.${mode}.local`, `.env.${mode}`, ".env.local", ".env"];
17
17
  }
18
- export function resolveEnvMode(processEnv, explicit) {
18
+ function resolveEnvMode(processEnv, explicit) {
19
19
  if (explicit !== undefined && explicit !== "")
20
20
  return explicit;
21
21
  const nodeEnv = processEnv.NODE_ENV;
@@ -44,7 +44,7 @@ export function resolveEnvCascade(input) {
44
44
  }
45
45
  return { mode, env: Object.freeze(env), candidates, loaded };
46
46
  }
47
- export function readEnvFileFromDisk(filePath) {
47
+ function readEnvFileFromDisk(filePath) {
48
48
  try {
49
49
  return readFileSync(filePath, "utf8");
50
50
  }
@@ -1,12 +1,10 @@
1
- import type { TsConfigJsonResolved } from "get-tsconfig";
2
- import type { ClientModuleStyle, ImportFileExtension, ModuleFormat } from "../emit/module-style.interface.js";
3
- import { type LocatedTsconfig } from "./tsconfig.locator.js";
1
+ import type { ClientModuleStyle } from "../emit/module-style.interface.js";
4
2
  /** What the run follows, and where it read it from. */
5
3
  export interface ResolvedModuleStyle extends ClientModuleStyle {
6
4
  /** The tsconfig the style was inferred from. */
7
5
  readonly tsconfig: string;
8
6
  }
9
- export interface ModuleStyleInput {
7
+ interface ModuleStyleInput {
10
8
  /** The absolute `generateAt`. */
11
9
  readonly generateAt: string;
12
10
  /** The generated entry file, absolute: the file the project must include. */
@@ -23,5 +21,4 @@ export interface ModuleStyleInput {
23
21
  export declare function resolveModuleStyle(input: ModuleStyleInput): ResolvedModuleStyle;
24
22
  /** The refusal of a project with no tsconfig: a JavaScript project. */
25
23
  export declare function noTsconfigRefusal(generateAt: string): string;
26
- export declare function importFileExtensionOf(config: TsConfigJsonResolved): ImportFileExtension;
27
- export declare function moduleFormatOf(tsconfig: LocatedTsconfig, generateAt: string): ModuleFormat;
24
+ export {};
@@ -20,7 +20,7 @@ export function noTsconfigRefusal(generateAt) {
20
20
  "(`npx tsc --init` writes one), or, if it has one this search does not find, name it with `tsconfigFile` " +
21
21
  "in your framework.client config. Nothing was written.");
22
22
  }
23
- export function importFileExtensionOf(config) {
23
+ function importFileExtensionOf(config) {
24
24
  const options = config.compilerOptions;
25
25
  if (options?.allowImportingTsExtensions ||
26
26
  options?.rewriteRelativeImportExtensions) {
@@ -32,7 +32,7 @@ export function importFileExtensionOf(config) {
32
32
  ? ""
33
33
  : "js";
34
34
  }
35
- export function moduleFormatOf(tsconfig, generateAt) {
35
+ function moduleFormatOf(tsconfig, generateAt) {
36
36
  const module = tsconfig.config.compilerOptions?.module?.toLowerCase();
37
37
  if (module === undefined || module === "") {
38
38
  return "esm";
@@ -4,7 +4,7 @@ export interface LocatedTsconfig {
4
4
  readonly path: string;
5
5
  readonly config: TsConfigJsonResolved;
6
6
  }
7
- export interface TsconfigLocation {
7
+ interface TsconfigLocation {
8
8
  /** The absolute `generateAt`: the search starts there. */
9
9
  readonly generateAt: string;
10
10
  /** The generated entry file the project must include, absolute. */
@@ -19,3 +19,4 @@ export interface TsconfigLocation {
19
19
  * @throws AvClientConfigError when the named `tsconfigFile` is missing or unreadable.
20
20
  */
21
21
  export declare function locateTsconfig(location: TsconfigLocation): LocatedTsconfig | undefined;
22
+ export {};
@@ -11,18 +11,30 @@ export function locateTsconfig(location) {
11
11
  return undefined;
12
12
  }
13
13
  if (found.config.references === undefined ||
14
- createFilesMatcher(found)(location.entryFile) !== undefined) {
14
+ includesEntry(found, location.entryFile)) {
15
15
  return found;
16
16
  }
17
17
  for (const reference of found.config.references) {
18
18
  const referenced = referencedTsconfig(found.path, reference.path);
19
19
  if (referenced !== undefined &&
20
- createFilesMatcher(referenced)(location.entryFile) !== undefined) {
20
+ includesEntry(referenced, location.entryFile)) {
21
21
  return referenced;
22
22
  }
23
23
  }
24
24
  return found;
25
25
  }
26
+ function includesEntry(tsconfig, file) {
27
+ const directory = path.dirname(tsconfig.path);
28
+ const local = (entries) => entries.map((entry) => path.isAbsolute(entry) ? path.relative(directory, entry) || "." : entry);
29
+ const { files, include, exclude } = tsconfig.config;
30
+ const config = {
31
+ ...tsconfig.config,
32
+ ...(files === undefined ? {} : { files: local(files) }),
33
+ ...(include === undefined ? {} : { include: local(include) }),
34
+ ...(exclude === undefined ? {} : { exclude: local(exclude) }),
35
+ };
36
+ return (createFilesMatcher({ path: tsconfig.path, config })(file) !== undefined);
37
+ }
26
38
  function namedTsconfig(file) {
27
39
  try {
28
40
  readFileSync(file, "utf8");
@@ -1,5 +1,4 @@
1
1
  import type { AvContractClient } from "@aventara/core";
2
- export declare const SUPPORTED_PROTOCOL_VERSIONS: ReadonlySet<number>;
3
2
  export type ContractRejectionReason = "protocol-unsupported" | "hash-mismatch" | "structure-invalid";
4
3
  export type ContractAccepted = {
5
4
  readonly accepted: true;
@@ -1,5 +1,5 @@
1
1
  import { AVENTARA_PROTOCOL_VERSION, AvCanonicalJsonError, computeClientContractHash, validateClientContractStructure, } from "@aventara/core/internal";
2
- export const SUPPORTED_PROTOCOL_VERSIONS = new Set([
2
+ const SUPPORTED_PROTOCOL_VERSIONS = new Set([
3
3
  AVENTARA_PROTOCOL_VERSION,
4
4
  ]);
5
5
  const REFUSED_EXIT_CODE = 1;
@@ -7,7 +7,7 @@ export type ContractFetch = (url: URL, init: {
7
7
  readonly headers?: Readonly<Record<string, string>>;
8
8
  }) => Promise<ContractResponse>;
9
9
  export declare const CONTRACT_NOT_MODIFIED: unique symbol;
10
- export type ContractTransportFailureReason =
10
+ type ContractTransportFailureReason =
11
11
  /** No response at all: DNS, refused connection, TLS, abort. */
12
12
  "request-failed"
13
13
  /** A response that is not 2xx — nothing was served for the protocol to judge. */
@@ -28,3 +28,4 @@ export declare function displayUrl(url: URL): string;
28
28
  * @throws AvContractTransportError when no JSON value arrives.
29
29
  */
30
30
  export declare function fetchClientContractBody(entrypoint: ClientEntrypoint, fetch?: ContractFetch, ifNoneMatch?: string): Promise<unknown>;
31
+ export {};
@@ -1,7 +1,7 @@
1
1
  import type { AvContractClient } from "@aventara/core";
2
2
  import type { ClientEntrypoint } from "../config/client-config.interface.js";
3
3
  import { type ContractFetch } from "./contract.fetcher.js";
4
- export interface ClientContractLoadInput {
4
+ interface ClientContractLoadInput {
5
5
  readonly entrypoint: ClientEntrypoint;
6
6
  readonly fetch?: ContractFetch;
7
7
  }
@@ -10,7 +10,7 @@ export interface ClientContractLoadInput {
10
10
  * @throws AvContractProtocolError when what arrived is refused.
11
11
  */
12
12
  export declare function loadClientContract(input: ClientContractLoadInput): Promise<AvContractClient>;
13
- export interface ClientContractSince {
13
+ interface ClientContractSince {
14
14
  readonly contract: AvContractClient;
15
15
  /** The deployment answered `304`: `contract` is the one the caller held. */
16
16
  readonly notModified: boolean;
@@ -22,3 +22,4 @@ export interface ClientContractSince {
22
22
  * `stored` the GET is unconditional.
23
23
  */
24
24
  export declare function loadClientContractSince(input: ClientContractLoadInput, stored: AvContractClient | undefined): Promise<ClientContractSince>;
25
+ export {};
@@ -0,0 +1,12 @@
1
+ import type { AvContractClient } from "@aventara/core";
2
+ import { ACCESS_VERB_NAMES, ACCESS_VERBS } from "@aventara/core/internal";
3
+ import type { EmittedModule } from "./emitted-tree.interface.js";
4
+ import { type ClientModuleStyle } from "./module-style.interface.js";
5
+ /** One verb `$access` offers beside `can`. */
6
+ export type AccessVerbName = (typeof ACCESS_VERB_NAMES)[number];
7
+ /** Whether a verb takes arguments, and what it resolves (core's verb table). */
8
+ export declare function accessVerbShape(name: AccessVerbName): (typeof ACCESS_VERBS)[AccessVerbName];
9
+ /** The verbs the section states, in `$access`'s order; none without `verbs`. */
10
+ export declare function accessVerbsOf(contract: AvContractClient): readonly AccessVerbName[];
11
+ /** `runtime/access.ts`, or nothing when the section states no verb. */
12
+ export declare function emitAccessModules(contract: AvContractClient, style: ClientModuleStyle): readonly EmittedModule[];
@@ -0,0 +1,263 @@
1
+ import { ACCESS_VERB_NAMES, ACCESS_VERBS } from "@aventara/core/internal";
2
+ import { importSpecifier, } from "./module-style.interface.js";
3
+ export function accessVerbShape(name) {
4
+ return ACCESS_VERBS[name];
5
+ }
6
+ export function accessVerbsOf(contract) {
7
+ const verbs = contract.access?.verbs;
8
+ return verbs === undefined
9
+ ? []
10
+ : ACCESS_VERB_NAMES.filter((name) => verbs[name] !== undefined);
11
+ }
12
+ export function emitAccessModules(contract, style) {
13
+ const verbs = contract.access?.verbs;
14
+ const names = accessVerbsOf(contract);
15
+ if (verbs === undefined || names.length === 0) {
16
+ return [];
17
+ }
18
+ const from = (module) => JSON.stringify(importSpecifier(module, style));
19
+ const table = names
20
+ .map((name) => {
21
+ const verb = verbs[name];
22
+ return `\t${name}: { method: ${JSON.stringify(verb?.method)}, path: ${JSON.stringify(verb?.path)}, result: ${JSON.stringify(verb?.result)} },\n`;
23
+ })
24
+ .join("");
25
+ return [
26
+ {
27
+ path: "runtime/access.ts",
28
+ source: `import { AvTransportError } from ${from("./errors")};
29
+ import type { AvCallOptions, PlatformSignal, TransportConnection } from ${from("./transport")};
30
+
31
+ /**
32
+ * Aventara Access's verbs, as this deployment's ClientContract states them:
33
+ * each one a request to Access's own route, its result projected onto the
34
+ * schema the ClientContract promises.
35
+ */
36
+
37
+ /**
38
+ * Aventara Access answered a verb with a status that is not 2xx. It carries the
39
+ * status and Access's own code and message; it is no AvError, because Access's
40
+ * answers are not framework envelopes.
41
+ */
42
+ export class AvAccessError extends Error {
43
+ override readonly name: string = "AvAccessError";
44
+ /** The HTTP status Access answered. */
45
+ readonly status: number;
46
+ /** Access's own error code, or null when its answer named none. */
47
+ readonly code: string | null;
48
+
49
+ constructor(message: string, status: number, code: string | null) {
50
+ super(message);
51
+ this.status = status;
52
+ this.code = code;
53
+ }
54
+ }
55
+
56
+ /** Where the app mounted Aventara Access. */
57
+ const ACCESS_URL = ${JSON.stringify(contract.access?.url ?? "")};
58
+
59
+ /** Each verb this client offers: its route under ACCESS_URL and its result's schema. */
60
+ const VERBS = {
61
+ ${table}} as const;
62
+
63
+ /** One verb this client offers. */
64
+ export type AccessVerb = keyof typeof VERBS;
65
+
66
+ /** A schema node, as the projection reads it. */
67
+ type Schema = { readonly [key: string]: unknown };
68
+
69
+ /** What a verb hands fetch. */
70
+ interface AccessInit {
71
+ readonly method: "GET" | "POST";
72
+ readonly headers: Readonly<Record<string, string>>;
73
+ readonly body?: string;
74
+ readonly cache?: "no-store";
75
+ readonly signal?: PlatformSignal;
76
+ }
77
+
78
+ /** What a verb reads of fetch's answer. */
79
+ interface AccessAnswer {
80
+ readonly status: number;
81
+ text(): Promise<string>;
82
+ }
83
+
84
+ /** The URL of a verb's route: a root-relative ACCESS_URL put under an absolute entrypoint's origin. */
85
+ function routeOf(entrypoint: string, path: string): string {
86
+ const base = ACCESS_URL.replace(/\\/+$/, "");
87
+ const origin = /^[a-z][a-z0-9+.-]*:\\/\\/[^/?#]*/i.exec(entrypoint)?.[0];
88
+ return base.startsWith("/") && origin !== undefined ? origin + base + path : base + path;
89
+ }
90
+
91
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
92
+ return typeof value === "object" && value !== null && !Array.isArray(value);
93
+ }
94
+
95
+ /**
96
+ * \`schema\` as a member's schema.
97
+ *
98
+ * @throws TypeError when it is none (a free-form object or list, a tuple read
99
+ * past its length): outside the forms a ClientContract states.
100
+ */
101
+ function memberSchema(schema: unknown): Schema {
102
+ if (!isRecord(schema)) {
103
+ throw new TypeError("the schema is not one of the forms a ClientContract states");
104
+ }
105
+ return schema as Schema;
106
+ }
107
+
108
+ /** Whether \`value\` can be read by \`schema\` at all: its JSON type. */
109
+ function fits(value: unknown, schema: Schema): boolean {
110
+ if (Object.hasOwn(schema, "const")) {
111
+ return value === schema["const"];
112
+ }
113
+ if (Array.isArray(schema["enum"])) {
114
+ return schema["enum"].includes(value);
115
+ }
116
+ for (const keyword of ["anyOf", "oneOf"]) {
117
+ const options = schema[keyword];
118
+ if (Array.isArray(options)) {
119
+ return options.some((option) => fits(value, option as Schema));
120
+ }
121
+ }
122
+ switch (schema["type"]) {
123
+ case "null":
124
+ return value === null;
125
+ case "boolean":
126
+ return typeof value === "boolean";
127
+ case "string":
128
+ return typeof value === "string";
129
+ case "number":
130
+ return typeof value === "number";
131
+ case "integer":
132
+ return Number.isInteger(value);
133
+ case "array":
134
+ return Array.isArray(value);
135
+ case "object":
136
+ return isRecord(value);
137
+ default:
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * \`value\` projected onto \`schema\`: every object down to the members the schema
144
+ * declares, a list member by member. The schema is the promise.
145
+ *
146
+ * @throws TypeError when the value is not one the schema describes.
147
+ */
148
+ function projected(value: unknown, schema: Schema): unknown {
149
+ if (!fits(value, schema)) {
150
+ throw new TypeError("the value is not the one the ClientContract describes");
151
+ }
152
+ for (const keyword of ["anyOf", "oneOf"]) {
153
+ const options = schema[keyword];
154
+ if (Array.isArray(options)) {
155
+ const option = options.find((candidate) => fits(value, candidate as Schema)) as Schema;
156
+ return projected(value, option);
157
+ }
158
+ }
159
+ if (Array.isArray(value)) {
160
+ const prefix = schema["prefixItems"];
161
+ const items = Array.isArray(prefix) ? undefined : memberSchema(schema["items"]);
162
+ return Object.freeze(
163
+ value.map((member: unknown, index: number) =>
164
+ projected(member, items ?? memberSchema((prefix as readonly unknown[])[index])),
165
+ ),
166
+ );
167
+ }
168
+ if (isRecord(value)) {
169
+ const properties = schema["properties"];
170
+ if (isRecord(properties)) {
171
+ const copy: Record<string, unknown> = {};
172
+ for (const [key, member] of Object.entries(properties)) {
173
+ if (Object.hasOwn(value, key) && value[key] !== undefined) {
174
+ copy[key] = projected(value[key], member as Schema);
175
+ }
176
+ }
177
+ return Object.freeze(copy);
178
+ }
179
+ const values = memberSchema(schema["additionalProperties"]);
180
+ return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, member]) => [key, projected(member, values)])));
181
+ }
182
+ return value;
183
+ }
184
+
185
+ /** Access's error answer, read for its code and message. */
186
+ function refusalOf(verb: AccessVerb, status: number, text: string): AvAccessError {
187
+ let body: unknown;
188
+ try {
189
+ body = JSON.parse(text);
190
+ } catch {
191
+ body = undefined;
192
+ }
193
+ const code = isRecord(body) && typeof body["code"] === "string" ? body["code"] : null;
194
+ const message =
195
+ isRecord(body) && typeof body["message"] === "string" ? body["message"] : "Aventara Access refused $access." + verb + " (HTTP " + status + ").";
196
+ return new AvAccessError(message, status, code);
197
+ }
198
+
199
+ /**
200
+ * Runs one Access verb: one request to its route, its 2xx answer projected onto
201
+ * its result schema (\`null\` stays \`null\`: no one is signed in).
202
+ *
203
+ * @throws AvAccessError on a non-2xx answer; AvTransportError when no answer
204
+ * arrived, or the answer is not the value the ClientContract describes. A
205
+ * caller's abort rejects with what fetch rejected with.
206
+ */
207
+ export async function callAccess(
208
+ connection: TransportConnection,
209
+ verb: AccessVerb,
210
+ args: unknown,
211
+ options: AvCallOptions = {},
212
+ ): Promise<unknown> {
213
+ const { method, path, result } = VERBS[verb];
214
+ const operation = "$access." + verb;
215
+ const headers: Record<string, string> = { ...options.headers };
216
+ let init: AccessInit;
217
+ if (method === "GET") {
218
+ init = { method, headers, cache: "no-store" };
219
+ } else {
220
+ headers["Content-Type"] = "application/json";
221
+ init = { method, headers, body: JSON.stringify(args ?? {}) };
222
+ }
223
+ if (options.signal !== undefined) {
224
+ init = { ...init, signal: options.signal };
225
+ }
226
+ const fetch = connection.fetch as unknown as (url: string, init: AccessInit) => Promise<AccessAnswer>;
227
+ let answer: AccessAnswer;
228
+ let text: string;
229
+ try {
230
+ answer = await fetch(routeOf(connection.entrypoint, path), init);
231
+ text = await answer.text();
232
+ } catch (error) {
233
+ if (options.signal?.aborted === true) {
234
+ throw error;
235
+ }
236
+ throw new AvTransportError("The request for " + operation + " failed before any answer arrived.", null);
237
+ }
238
+ if (answer.status < 200 || answer.status > 299) {
239
+ throw refusalOf(verb, answer.status, text);
240
+ }
241
+ let value: unknown;
242
+ try {
243
+ value = text.trim() === "" ? null : JSON.parse(text);
244
+ } catch {
245
+ throw new AvTransportError("The answer to " + operation + " (HTTP " + answer.status + ") is not JSON.", answer.status);
246
+ }
247
+ if (value === null) {
248
+ return null;
249
+ }
250
+ try {
251
+ return projected(value, result as Schema);
252
+ } catch (error) {
253
+ throw new AvTransportError(
254
+ "The answer to " + operation + " (HTTP " + answer.status + ") holds a value this client cannot read: " +
255
+ (error instanceof Error ? error.message : String(error)),
256
+ answer.status,
257
+ );
258
+ }
259
+ }
260
+ `,
261
+ },
262
+ ];
263
+ }
@@ -1,7 +1,12 @@
1
1
  export declare const GENERATED_OWNERSHIP_LINE = "/* !!! Generated by @aventara/client. Do not edit. !!! */";
2
+ /** The command a generated file says to regenerate it with: a command in backticks, then any words about it. */
3
+ export type RegenerateCommand = `\`${string}\`${string}`;
4
+ /** `avclient generate`'s own: what its banner and errors say. */
5
+ export declare const AVCLIENT_REGENERATE: RegenerateCommand;
6
+ /** `avclient generate`'s banner. */
2
7
  export declare const GENERATED_BANNER: readonly string[];
3
8
  /** `source` with the banner before it and one blank line between. */
4
- export declare function withGeneratedBanner(source: string): string;
9
+ export declare function withGeneratedBanner(source: string, regenerate?: RegenerateCommand): string;
5
10
  /**
6
11
  * How far into a file the ownership line is looked for. The banner opens every
7
12
  * emitted file, so the line sits well inside this; a reader never has to read a
@@ -1,12 +1,16 @@
1
1
  export const GENERATED_OWNERSHIP_LINE = "/* !!! Generated by @aventara/client. Do not edit. !!! */";
2
- export const GENERATED_BANNER = [
3
- "// biome-ignore-all format: generated output",
4
- "// biome-ignore-all lint: generated output",
5
- GENERATED_OWNERSHIP_LINE,
6
- "/* Regenerate with `avclient generate`. */",
7
- ];
8
- export function withGeneratedBanner(source) {
9
- return `${GENERATED_BANNER.join("\n")}\n\n${source}`;
2
+ export const AVCLIENT_REGENERATE = "`avclient generate`";
3
+ function generatedBanner(regenerate = AVCLIENT_REGENERATE) {
4
+ return [
5
+ "// biome-ignore-all format: generated output",
6
+ "// biome-ignore-all lint: generated output",
7
+ GENERATED_OWNERSHIP_LINE,
8
+ `/* Regenerate with ${regenerate}. */`,
9
+ ];
10
+ }
11
+ export const GENERATED_BANNER = generatedBanner();
12
+ export function withGeneratedBanner(source, regenerate = AVCLIENT_REGENERATE) {
13
+ return `${generatedBanner(regenerate).join("\n")}\n\n${source}`;
10
14
  }
11
15
  export const GENERATED_OWNERSHIP_HEAD_BYTES = 1024;
12
16
  export function carriesGeneratedOwnership(head) {
@@ -0,0 +1,3 @@
1
+ import type { EmittedModule } from "./emitted-tree.interface.js";
2
+ import { type ClientModuleStyle } from "./module-style.interface.js";
3
+ export declare function emitCachedReadModules(style: ClientModuleStyle): readonly EmittedModule[];