@aventara/build 0.0.0-stage → 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.
package/dist/cli.js ADDED
@@ -0,0 +1,240 @@
1
+ import path from "node:path";
2
+ import { AvBuildError } from "./build.error.js";
3
+ import { checkBrowserClient, emitBrowserClient, writeBrowserClient, } from "./client/client-file.js";
4
+ import { readConfigComputedFields } from "./computed/computed-fields.reader.js";
5
+ import { emitComputedFieldsModule } from "./computed/computed-fields-file.js";
6
+ import { checkContractsFile, writeContractsFile, } from "./contracts/contracts-file.js";
7
+ import { compilePrebuiltContracts } from "./contracts/prebuilt-contracts.compiler.js";
8
+ import { readGeneratedFile, writeGeneratedFile, } from "./project/generated-file.js";
9
+ import { DEFAULT_GENERATED_FILES, resolveGeneratedFiles, } from "./project/generated-files.resolver.js";
10
+ import { applyProjectEnv } from "./project/project-env.loader.js";
11
+ import { PROJECT_LAYOUTS, resolveProjectLayout, } from "./project/project-layout.resolver.js";
12
+ import { findProjectModules, loadProjectModules, } from "./project/project-modules.loader.js";
13
+ import { importSuffixOf, projectCompilerOptions, } from "./project/tsconfig.reader.js";
14
+ import { RESOURCES_GLOB, readResourceDefinitions, } from "./resources/resource-definitions.reader.js";
15
+ import { emitResourcesModule } from "./resources/resources-file.js";
16
+ const USAGE = `aventara-build — compile this project's Aventara Contracts ahead of time.
17
+
18
+ Usage:
19
+ aventara-build write the three files below
20
+ aventara-build --client also write a Next.js app's browser client
21
+ aventara-build --check [--client] write nothing; exit 1 when one is missing or stale
22
+
23
+ Run it from the project's root. It writes, in this order:
24
+
25
+ ${DEFAULT_GENERATED_FILES.resources}
26
+ \`aventaraResources\`: every \`defineResource(…)\` result the files
27
+ ${RESOURCES_GLOB} export, one \`resources\` tree per layer;
28
+ ${DEFAULT_GENERATED_FILES.computedFields}
29
+ which fields the configuration computes, read off its type;
30
+ ${DEFAULT_GENERATED_FILES.contracts}
31
+ the compiled Contracts, \`aventaraContracts\`.
32
+
33
+ All three go to the directory the configuration imports \`aventaraResources\`
34
+ from (src/aventara/ when \`aventara init\` found the ORM's client generated into
35
+ src/generated), else to ${PROJECT_LAYOUTS.nest.generated}/ (${PROJECT_LAYOUTS.next.generated}/
36
+ in a Next.js project).
37
+
38
+ It runs the configuration (its \`aventaraConfig\` export or its default,
39
+ without \`adapter\`) and the adapter's description (its default export, such
40
+ as \`describePrismaAdapter({ discovery, provider, driver })\`), in TypeScript
41
+ or JavaScript, and compiles the Contracts with the project's own
42
+ @aventara/core. It never constructs a database client. Before it runs them it
43
+ reads the project's .env files into the environment, as \`avclient generate\`
44
+ does: a variable already set wins, then .env.<mode>.local, .env.<mode>,
45
+ .env.local and .env (the mode is NODE_ENV, else development). Where they are
46
+ depends on the project's host:
47
+
48
+ a Next.js project (its package.json depends on @aventara/next)
49
+ ${PROJECT_LAYOUTS.next.config}.ts and ${PROJECT_LAYOUTS.next.adapter}.ts
50
+ any other project (NestJS)
51
+ ${PROJECT_LAYOUTS.nest.config}.ts and ${PROJECT_LAYOUTS.nest.adapter}.ts
52
+
53
+ With --client, a Next.js project also gets its browser client, written from
54
+ the ClientContract just compiled: ${PROJECT_LAYOUTS.next.client.folder}/${PROJECT_LAYOUTS.next.client.entry}
55
+ (\`avClient\`, calling the app's own \`api\` from the browser, and \`avHydrate\`),
56
+ ${PROJECT_LAYOUTS.next.client.folder}/${PROJECT_LAYOUTS.next.client.universal} (\`avClient\` for both sides: the app's \`api\`
57
+ from the browser, the app in process on the server) and the client's modules
58
+ under ${PROJECT_LAYOUTS.next.client.folder}/generated/.
59
+
60
+ A serverless or edge host passes the file's \`aventaraContracts\` to
61
+ createFramework as \`prebuilt\`; a long-running server needs none.
62
+
63
+ Options:
64
+ --client Also write the browser client (Next.js projects).
65
+ --check Exit 1, writing nothing, when a file is missing or stale.
66
+ -h, --help Print this and exit 0.`;
67
+ function parse(argv) {
68
+ let check = false;
69
+ let client = false;
70
+ for (const argument of argv) {
71
+ if (argument === "--check") {
72
+ check = true;
73
+ continue;
74
+ }
75
+ if (argument === "--client") {
76
+ client = true;
77
+ continue;
78
+ }
79
+ throw new AvBuildError(`unknown argument \`${argument}\`.\n\n${USAGE}`);
80
+ }
81
+ return { check, client };
82
+ }
83
+ function listed(items) {
84
+ return items.length < 2
85
+ ? items.join("")
86
+ : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
87
+ }
88
+ function counted(count) {
89
+ return `${count} Resource${count === 1 ? "" : "s"}`;
90
+ }
91
+ function wrote(written, file, detail) {
92
+ return `aventara-build: ${written === "written" ? "wrote" : "unchanged:"} ${file}${detail === undefined ? "" : ` (${detail})`}.`;
93
+ }
94
+ function checked(projectDirectory, file, text, stale) {
95
+ const current = readGeneratedFile(projectDirectory, file);
96
+ return current === undefined
97
+ ? { file, what: "is missing" }
98
+ : current === text
99
+ ? undefined
100
+ : { file, what: `is stale: ${stale}` };
101
+ }
102
+ function refusal({ file, what, deploy }) {
103
+ return `aventara-build: ${file} ${what}. Run aventara-build (npm run aventara:prepare)${deploy === true ? " and deploy the file it writes" : ""}.`;
104
+ }
105
+ export async function runBuild(argv, projectDirectory, output) {
106
+ if (argv.includes("-h") || argv.includes("--help")) {
107
+ output.stdout(USAGE);
108
+ return 0;
109
+ }
110
+ let restoreEnv;
111
+ try {
112
+ const { check, client } = parse(argv);
113
+ const env = applyProjectEnv(projectDirectory);
114
+ restoreEnv = env.restore;
115
+ if (env.loaded.length > 0) {
116
+ output.stdout(`aventara-build: environment from ${listed(env.loaded)} (a variable already set keeps its value).`);
117
+ }
118
+ const layout = resolveProjectLayout(projectDirectory);
119
+ const clientLayout = client ? layout.client : undefined;
120
+ if (client && clientLayout === undefined) {
121
+ throw new AvBuildError(`--client writes the browser client of a Next.js project, and ${projectDirectory} is ${layout.described}. A separate frontend generates its client from the running server with \`avclient generate\`.`);
122
+ }
123
+ const files = findProjectModules(projectDirectory, layout);
124
+ const generated = resolveGeneratedFiles(projectDirectory, files.configFile, layout.generated);
125
+ const options = projectCompilerOptions(projectDirectory);
126
+ const found = readResourceDefinitions(projectDirectory, options);
127
+ const tree = emitResourcesModule(projectDirectory, generated.resources, found.definitions, importSuffixOf(options, path.dirname(path.join(projectDirectory, generated.resources))));
128
+ for (const file of found.withoutDefinition) {
129
+ output.stdout(`aventara-build: ${file} exports no defineResource(…) result; it is not in aventaraResources.`);
130
+ }
131
+ if (check) {
132
+ const stale = checked(projectDirectory, generated.resources, tree, "the Resource files changed since it was written");
133
+ if (stale !== undefined) {
134
+ output.stderr(refusal(stale));
135
+ return 1;
136
+ }
137
+ }
138
+ else {
139
+ output.stdout(wrote(writeGeneratedFile(projectDirectory, generated.resources, tree), generated.resources, counted(found.definitions.length)));
140
+ }
141
+ if (check) {
142
+ if (readGeneratedFile(projectDirectory, generated.computedFields) ===
143
+ undefined) {
144
+ output.stderr(refusal({ file: generated.computedFields, what: "is missing" }));
145
+ return 1;
146
+ }
147
+ }
148
+ else if (readGeneratedFile(projectDirectory, generated.computedFields) ===
149
+ undefined) {
150
+ writeGeneratedFile(projectDirectory, generated.computedFields, emitComputedFieldsModule(files.configFile, {
151
+ kind: "unread",
152
+ reason: "the build has not read the configuration yet",
153
+ }));
154
+ }
155
+ const reading = readConfigComputedFields(path.join(projectDirectory, files.configFile), options, found.program);
156
+ const computed = emitComputedFieldsModule(files.configFile, reading);
157
+ if (check) {
158
+ const stale = checked(projectDirectory, generated.computedFields, computed, "the fields the configuration computes changed since it was written");
159
+ if (stale !== undefined) {
160
+ output.stderr(refusal(stale));
161
+ return 1;
162
+ }
163
+ }
164
+ else {
165
+ output.stdout(wrote(writeGeneratedFile(projectDirectory, generated.computedFields, computed), generated.computedFields));
166
+ if (reading.kind === "unread") {
167
+ output.stdout(`aventara-build: computed fields not read from ${files.configFile}: ${reading.reason}. The stages before compute may leave any create field out; write the configuration \`as const\` there.`);
168
+ }
169
+ }
170
+ const project = await loadProjectModules(projectDirectory, files);
171
+ const compiled = await compilePrebuiltContracts(project);
172
+ const resources = Object.keys(compiled.prebuilt.fingerprints.resources).length;
173
+ const browserClient = clientLayout === undefined
174
+ ? undefined
175
+ : await emitBrowserClient(projectDirectory, clientLayout, {
176
+ client: compiled.client,
177
+ api: compiled.api,
178
+ canonicalText: (contract) => project.internal.canonicalizeContract(contract),
179
+ });
180
+ if (!check) {
181
+ output.stdout(wrote(writeContractsFile(projectDirectory, generated.contracts, compiled), generated.contracts, counted(resources)));
182
+ if (browserClient !== undefined) {
183
+ for (const warning of browserClient.warnings) {
184
+ output.stdout(`aventara-build: ${warning}`);
185
+ }
186
+ const written = writeBrowserClient(projectDirectory, browserClient);
187
+ for (const file of written.removed) {
188
+ output.stdout(`aventara-build: removed ${file}, which this ClientContract's browser client no longer has.`);
189
+ }
190
+ const [entry, universal] = browserClient.files;
191
+ output.stdout(wrote(written.write, `${entry?.file}, ${universal?.file} and ${browserClient.tree}/`, `the browser client, calling ${browserClient.entrypoint === "" ? "/" : browserClient.entrypoint}`));
192
+ }
193
+ return 0;
194
+ }
195
+ const contracts = await checkContractsFile(projectDirectory, generated.contracts, compiled, project.internal.prebuiltFileChanges);
196
+ if (contracts.kind === "fresh") {
197
+ const stale = browserClient === undefined
198
+ ? undefined
199
+ : checkBrowserClient(projectDirectory, browserClient);
200
+ if (stale !== undefined) {
201
+ output.stderr(refusal(stale));
202
+ return 1;
203
+ }
204
+ output.stdout(browserClient === undefined
205
+ ? `aventara-build: ${generated.resources}, ${generated.computedFields} and ${generated.contracts} are up to date.`
206
+ : `aventara-build: ${generated.resources}, ${generated.computedFields}, ${generated.contracts} and the browser client (${browserClient.files[0]?.file}, ${browserClient.files[1]?.file} and ${browserClient.tree}/) are up to date.`);
207
+ return 0;
208
+ }
209
+ output.stderr(refusal({
210
+ file: generated.contracts,
211
+ deploy: true,
212
+ what: contracts.kind === "missing"
213
+ ? "is missing"
214
+ : `is stale: ${contracts.changes.join("; ")}`,
215
+ }));
216
+ return 1;
217
+ }
218
+ catch (error) {
219
+ if (error instanceof AvBuildError) {
220
+ output.stderr(`aventara-build: ${error.message}`);
221
+ return 1;
222
+ }
223
+ throw error;
224
+ }
225
+ finally {
226
+ restoreEnv?.();
227
+ }
228
+ }
229
+ export async function runFromProcess() {
230
+ try {
231
+ process.exitCode = await runBuild(process.argv.slice(2), process.cwd(), {
232
+ stdout: (line) => process.stdout.write(`${line}\n`),
233
+ stderr: (line) => process.stderr.write(`${line}\n`),
234
+ });
235
+ }
236
+ catch (error) {
237
+ process.stderr.write(`${(error instanceof Error && error.stack) || String(error)}\n`);
238
+ process.exitCode = 1;
239
+ }
240
+ }
@@ -0,0 +1,449 @@
1
+ import { readdirSync, rmSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { AvClientConfigError, AvGeneratedNameError, acceptClientContract, accessVerbsOf, carriesGeneratedOwnership, clientEntryReExports, emitClientTree, GENERATED_DIRECTORY, HYDRATION_FORMAT, importSpecifier, resolveModuleStyle, sameOriginEntrypoint, } from "@aventara/client/internal";
4
+ import { AvBuildError } from "../build.error.js";
5
+ import { readGeneratedFile, writeGeneratedFile, } from "../project/generated-file.js";
6
+ export const REGENERATE = "`aventara-build --client` (the project's `aventara:prepare` script)";
7
+ const ENTRY_BANNER = [
8
+ "// biome-ignore-all format: generated output",
9
+ "// biome-ignore-all lint: generated output",
10
+ "/* !!! Generated by @aventara/build. Do not edit. !!! */",
11
+ `/* Regenerate with ${REGENERATE}. */`,
12
+ ].join("\n");
13
+ export const SERVER_CALL_REFUSAL = "avClient from @/aventara/client runs in the browser only, and this call ran on the server, where a Client Component renders first: on the server, call avClient from @/aventara/server, or from @/aventara/universal for data the browser then hydrates.";
14
+ const BROWSER_GUARD_SOURCE = `/** \`call\`, rejected instead of run when \`refusal()\` names why. */
15
+ function guarded(call: Call): Call {
16
+ return (...args) => {
17
+ const refused = refusal();
18
+ return refused === undefined ? call(...args) : Promise.reject(new Error(refused));
19
+ };
20
+ }
21
+ `;
22
+ const UNIVERSAL_GUARD_SOURCE = `/**
23
+ * Next.js's \`io()\`: while \`next build\` prerenders a page it waits, so a call
24
+ * there runs at request time, as any database read does; it resolves at once
25
+ * everywhere else. Next.js refuses it in an \`after()\` callback a page
26
+ * scheduled, where the render is over, and the call then runs at once.
27
+ */
28
+ function ioFirst(): Promise<void> {
29
+ try {
30
+ return io();
31
+ } catch {
32
+ return Promise.resolve();
33
+ }
34
+ }
35
+
36
+ /**
37
+ * \`call\`, made at once when it may run (its request awaits \`io()\`); when
38
+ * \`refusal()\` names why it may not, made once \`io()\` lets it if it may run
39
+ * then, and rejected instead otherwise.
40
+ */
41
+ function guarded(call: Call): Call {
42
+ return (...args) =>
43
+ refusal() === undefined
44
+ ? call(...args)
45
+ : ioFirst().then(() => {
46
+ const refused = refusal();
47
+ return refused === undefined ? call(...args) : Promise.reject(new Error(refused));
48
+ });
49
+ }
50
+ `;
51
+ const GUARD_SOURCE = `/**
52
+ * A frozen copy of one Resource's families, or one family's variants: each call
53
+ * guarded, and made on what \`at\` reads of the \`current()\` client.
54
+ */
55
+ function guardedTree(tree: object, at: (client: AvClient) => unknown): object {
56
+ const copy: Record<string, unknown> = Object.create(Object.getPrototypeOf(tree) as object | null);
57
+ for (const [key, value] of Object.entries(tree)) {
58
+ const member = (client: AvClient): unknown => (at(client) as Readonly<Record<string, unknown>>)[key];
59
+ copy[key] =
60
+ typeof value === "function"
61
+ ? guarded((...args) => (member(current()) as Call)(...args))
62
+ : guardedTree(value as object, member);
63
+ }
64
+ return Object.freeze(copy);
65
+ }
66
+
67
+ /**
68
+ * A client shaped as \`template\`, every call that sends a request guarded and
69
+ * made on the \`current()\` client; \`tx\` builds deferred operations and sends
70
+ * nothing, so the template's is kept as it is.
71
+ */
72
+ function guardedClient(template: AvClient): AvClient {
73
+ const copy: AvClient = Object.create(AvClient.prototype) as AvClient;
74
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(template))) {
75
+ const value: unknown = descriptor.value;
76
+ const member = (client: AvClient): unknown => (client as unknown as Readonly<Record<string, unknown>>)[key];
77
+ Object.defineProperty(copy, key, {
78
+ ...descriptor,
79
+ value:
80
+ key === "tx"
81
+ ? value
82
+ : typeof value === "function"
83
+ ? guarded((...args) => (member(current()) as Call)(...args))
84
+ : guardedTree(value as object, member),
85
+ });
86
+ }
87
+ return copy;
88
+ }
89
+ `;
90
+ function entrySource(style, accessVerbs) {
91
+ const from = (module) => JSON.stringify(importSpecifier(module, style));
92
+ const { tagKey, valueKey, objectTag, scalarTags } = HYDRATION_FORMAT;
93
+ const tagged = [...scalarTags, objectTag]
94
+ .map((tag) => JSON.stringify(tag))
95
+ .join(" | ");
96
+ return `${ENTRY_BANNER}
97
+
98
+ import { AvClient } from ${from(`./${GENERATED_DIRECTORY}/client`)};
99
+ import { type AvBuiltInScalar, decodeScalar } from ${from(`./${GENERATED_DIRECTORY}/runtime/codec`)};
100
+
101
+ ${clientEntryReExports(style, "type", accessVerbs)}
102
+ /** What a call made on the server is refused with. */
103
+ const SERVER_CALL = ${JSON.stringify(SERVER_CALL_REFUSAL)};
104
+
105
+ /**
106
+ * Whether this code runs where the client's entrypoint, a path on the page's own
107
+ * origin, can be resolved: a page or a worker has a location, a server has none.
108
+ */
109
+ function onServer(): boolean {
110
+ return (globalThis as { readonly location?: unknown }).location === undefined;
111
+ }
112
+
113
+ /** Why a call made here and now is refused; \`undefined\` when it may run. */
114
+ function refusal(): string | undefined {
115
+ return onServer() ? SERVER_CALL : undefined;
116
+ }
117
+
118
+ type Call = (...args: unknown[]) => unknown;
119
+
120
+ /** The one client every call is made on. */
121
+ const CLIENT = new AvClient();
122
+
123
+ /** The client a call made here and now is made on: the one client. */
124
+ function current(): AvClient {
125
+ return CLIENT;
126
+ }
127
+
128
+ ${BROWSER_GUARD_SOURCE}
129
+ ${GUARD_SOURCE}
130
+ /**
131
+ * This app's client, calling its own origin's API from the browser. A call made
132
+ * on the server (while a Client Component renders there) rejects instead: on the
133
+ * server, import \`avClient\` from \`@/aventara/server\` or \`@/aventara/universal\`.
134
+ */
135
+ export const avClient: AvClient = guardedClient(CLIENT);
136
+
137
+ /** The key a dehydrated value's tag is under: \`{ ${JSON.stringify(tagKey)}: <scalar>, ${JSON.stringify(valueKey)}: <its wire form> }\`. */
138
+ const TAG = ${JSON.stringify(tagKey)};
139
+
140
+ /** The key a tagged value's wire form is under. */
141
+ const VALUE = ${JSON.stringify(valueKey)};
142
+
143
+ /** The tag of a plain object holding a ${JSON.stringify(tagKey)} key of its own, kept as data. */
144
+ const OBJECT_TAG = ${JSON.stringify(objectTag)};
145
+
146
+ /** The scalars a value may be tagged as. */
147
+ const TAGGED_SCALARS: ReadonlySet<string> = new Set<AvBuiltInScalar>([${scalarTags.map((tag) => JSON.stringify(tag)).join(", ")}]);
148
+
149
+ function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
150
+ if (typeof value !== "object" || value === null) {
151
+ return false;
152
+ }
153
+ const prototype: unknown = Object.getPrototypeOf(value);
154
+ return prototype === Object.prototype || prototype === null;
155
+ }
156
+
157
+ function revivedMembers(record: Readonly<Record<string, unknown>>): Record<string, unknown> {
158
+ return Object.fromEntries(Object.entries(record).map(([key, member]) => [key, avHydrate(member)]));
159
+ }
160
+
161
+ /**
162
+ * Revives data that \`avDehydrate\` (from \`@aventara/next\`) made safe to pass
163
+ * from the server to the browser: each tagged Date, bigint, Uint8Array and
164
+ * decimal becomes the value this client reads, a decimal this client's own
165
+ * AvDecimal.
166
+ * Arrays and plain objects are copied with their members revived; any other
167
+ * value is returned as it is. Give it to TanStack Query as \`deserializeData\`,
168
+ * or call it on an SWR \`fallback\`.
169
+ *
170
+ * @throws TypeError when a tagged value is not one avDehydrate writes.
171
+ */
172
+ export function avHydrate(dehydrated: unknown): unknown {
173
+ if (Array.isArray(dehydrated)) {
174
+ return dehydrated.map((member: unknown) => avHydrate(member));
175
+ }
176
+ if (!isPlainObject(dehydrated)) {
177
+ return dehydrated;
178
+ }
179
+ if (!Object.hasOwn(dehydrated, TAG)) {
180
+ return revivedMembers(dehydrated);
181
+ }
182
+ const tag = dehydrated[TAG];
183
+ const wire = dehydrated[VALUE];
184
+ if (Object.keys(dehydrated).length === 2 && Object.hasOwn(dehydrated, VALUE)) {
185
+ if (tag === OBJECT_TAG && isPlainObject(wire)) {
186
+ return revivedMembers(wire);
187
+ }
188
+ if (typeof tag === "string" && TAGGED_SCALARS.has(tag)) {
189
+ return decodeScalar(tag as AvBuiltInScalar, wire);
190
+ }
191
+ }
192
+ throw new TypeError(
193
+ ${JSON.stringify(`A value tagged ${JSON.stringify(tagKey)} must be { ${JSON.stringify(tagKey)}: ${tagged}, ${JSON.stringify(valueKey)}: its value }, as avDehydrate writes it.`)},
194
+ );
195
+ }
196
+ `;
197
+ }
198
+ export const UNIVERSAL_PATH_KEY = "aventara.next.universal";
199
+ export const UNREGISTERED_REFUSAL = 'avClient from @/aventara/universal ran on the server, where it calls this app in process through what ready() registers, and nothing is registered: export register() from src/instrumentation.ts and, when process.env.NEXT_RUNTIME is "nodejs", await ready() from @/aventara/server there.';
200
+ function universalSource(style, accessVerbs) {
201
+ const from = (module) => JSON.stringify(importSpecifier(module, style));
202
+ return `${ENTRY_BANNER}
203
+
204
+ import { io } from ${JSON.stringify(style.importFileExtension === "" ? "next/cache" : "next/cache.js")};
205
+ import { cache } from "react";
206
+ import { AvClient } from ${from(`./${GENERATED_DIRECTORY}/client`)};
207
+ import type { AvFetch } from ${from(`./${GENERATED_DIRECTORY}/runtime/transport`)};
208
+
209
+ import { avLazy as lazyCall } from ${from(`./${GENERATED_DIRECTORY}/runtime/operation-set`)};
210
+
211
+ /** The key the server's in-process path is registered under, on globalThis, by ready() from @aventara/next. */
212
+ const SERVER_PATH = Symbol.for(${JSON.stringify(UNIVERSAL_PATH_KEY)});
213
+
214
+ /** What a call made on a server where nothing is registered is refused with. */
215
+ const UNREGISTERED = ${JSON.stringify(UNREGISTERED_REFUSAL)};
216
+
217
+ /** Whether this code runs on the server: a page or a worker has a location, a server has none. */
218
+ function onServer(): boolean {
219
+ return (globalThis as { readonly location?: unknown }).location === undefined;
220
+ }
221
+
222
+ /** The in-process path ready() registered on this server, or undefined. */
223
+ function serverPath(): AvFetch | undefined {
224
+ const registered = (globalThis as { readonly [key: symbol]: unknown })[SERVER_PATH];
225
+ return typeof registered === "function" ? (registered as AvFetch) : undefined;
226
+ }
227
+
228
+ /** Why a call made here and now is refused; \`undefined\` when it may run. */
229
+ function refusal(): string | undefined {
230
+ return onServer() && serverPath() === undefined ? UNREGISTERED : undefined;
231
+ }
232
+
233
+ /** Once Next's \`io()\` lets it: in the browser, the platform's fetch; on the server, the in-process path. */
234
+ async function universalFetch(...request: unknown[]): Promise<unknown> {
235
+ await ioFirst();
236
+ const reach = onServer() ? serverPath() : (globalThis as { readonly fetch?: AvFetch }).fetch;
237
+ if (reach === undefined) {
238
+ throw new TypeError(onServer() ? UNREGISTERED : "No fetch is available here.");
239
+ }
240
+ return (reach as (...request: unknown[]) => unknown)(...request);
241
+ }
242
+
243
+ type Call = (...args: unknown[]) => unknown;
244
+
245
+ /** The browser's one client, whose calls are grouped, and the shape \`avClient\` copies. */
246
+ const BROWSER = new AvClient({ fetch: universalFetch as AvFetch });
247
+
248
+ /**
249
+ * The client a call made here and now is made on: the browser's one; on the
250
+ * server, a client of the call's own with grouping off, so the call is sent
251
+ * alone, from the async context it was made in (the request's, or a scope
252
+ * nested in it), and shares no queue with any other call.
253
+ */
254
+ function current(): AvClient {
255
+ return onServer() ? new AvClient({ fetch: universalFetch as AvFetch, grouping: false }) : BROWSER;
256
+ }
257
+
258
+ /** On the server, the calls each avLazy made in the request being rendered (React's \`cache\`). */
259
+ const requestCalls = cache((): WeakMap<object, Promise<unknown>> => new WeakMap());
260
+
261
+ /**
262
+ * Describe a call now, make it later: the tree's avLazy, which avClient.all
263
+ * accepts on either side. In the browser it makes the call once and keeps its
264
+ * answer. On the server it keeps one answer per request: inside a React
265
+ * request scope (a render), each request makes the call once, in its own
266
+ * context; where React keeps no request (a route handler, a Server Action),
267
+ * it keeps nothing, and each await makes the call again.
268
+ *
269
+ * @example const posts = avLazy(() => avClient.Post.find.many({}));
270
+ */
271
+ export function avLazy<T>(run: () => PromiseLike<T>): Promise<T> {
272
+ const key = {};
273
+ return lazyCall(() => {
274
+ if (!onServer()) {
275
+ return run();
276
+ }
277
+ const calls = requestCalls();
278
+ const made = calls.get(key);
279
+ if (made !== undefined) {
280
+ return made as Promise<T>;
281
+ }
282
+ const call = Promise.resolve(run());
283
+ calls.set(key, call);
284
+ return call;
285
+ });
286
+ }
287
+
288
+ ${UNIVERSAL_GUARD_SOURCE}
289
+ ${GUARD_SOURCE}
290
+ /**
291
+ * This app's client, on the server and in the browser alike: in the browser it
292
+ * calls the app's own origin's API; on the server it calls the app in process,
293
+ * with the request's state, through what \`ready()\` registered at startup
294
+ * (\`src/instrumentation.ts\`). Its values are the same on both sides, so data
295
+ * prefetched on the server hydrates in the browser: pass it through
296
+ * \`avDehydrate\` (\`@aventara/next\`) and \`avHydrate\` (\`@/aventara/client\`).
297
+ */
298
+ export const avClient: AvClient = ${accessVerbs ? "withServerAccess(" : ""}guardedClient(BROWSER)${accessVerbs ? ")" : ""};
299
+ ${accessVerbs ? SERVER_ACCESS_SOURCE : ""}`;
300
+ }
301
+ const UNIVERSAL_ACCESS_KEY = "aventara.next.universal.access";
302
+ const SERVER_ACCESS_SOURCE = `
303
+ /** The key ready() registers avClient.$access under, on globalThis, for the Access verbs on the server. */
304
+ const SERVER_ACCESS = Symbol.for(${JSON.stringify(UNIVERSAL_ACCESS_KEY)});
305
+
306
+ type AccessVerb = (...args: unknown[]) => unknown;
307
+
308
+ /** \`$access\`: \`can\` as it is; each Access verb, on the server, through what ready() registered. */
309
+ function serverAccess(verbs: Readonly<Record<string, AccessVerb>>): object {
310
+ const routed: Record<string, AccessVerb> = {};
311
+ for (const [name, verb] of Object.entries(verbs)) {
312
+ routed[name] =
313
+ name === "can"
314
+ ? verb
315
+ : (...args) => {
316
+ if (!onServer()) {
317
+ return verb(...args);
318
+ }
319
+ const registered = (globalThis as { readonly [key: symbol]: unknown })[SERVER_ACCESS] as
320
+ | Readonly<Record<string, unknown>>
321
+ | undefined;
322
+ const server = registered?.[name];
323
+ return typeof server === "function"
324
+ ? ioFirst().then(() => (server as AccessVerb)(...args))
325
+ : Promise.reject(new Error(UNREGISTERED));
326
+ };
327
+ }
328
+ return Object.freeze(routed);
329
+ }
330
+
331
+ /** The client with its \`$access\` routed by \`serverAccess\`. */
332
+ function withServerAccess(client: AvClient): AvClient {
333
+ const copy: AvClient = Object.create(AvClient.prototype) as AvClient;
334
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(client))) {
335
+ Object.defineProperty(
336
+ copy,
337
+ key,
338
+ key === "$access" ? { ...descriptor, value: serverAccess(descriptor.value as Readonly<Record<string, AccessVerb>>) } : descriptor,
339
+ );
340
+ }
341
+ return copy;
342
+ }
343
+ `;
344
+ function refusedAsBuild(step) {
345
+ try {
346
+ return step();
347
+ }
348
+ catch (error) {
349
+ if (error instanceof AvClientConfigError ||
350
+ error instanceof AvGeneratedNameError) {
351
+ throw new AvBuildError(`the browser client cannot be written: ${error.message}`, { cause: error });
352
+ }
353
+ throw error;
354
+ }
355
+ }
356
+ export async function emitBrowserClient(projectDirectory, layout, source) {
357
+ const acceptance = await acceptClientContract(JSON.parse(source.canonicalText(source.client)));
358
+ if (!acceptance.accepted) {
359
+ throw new AvBuildError(`the ClientContract this build compiled was refused by the client generator: ${acceptance.message}`);
360
+ }
361
+ const folder = path.join(projectDirectory, layout.folder);
362
+ const style = refusedAsBuild(() => resolveModuleStyle({
363
+ generateAt: folder,
364
+ entryFile: path.join(folder, layout.entry),
365
+ }));
366
+ const entrypoint = refusedAsBuild(() => sameOriginEntrypoint(source.api));
367
+ const emission = refusedAsBuild(() => emitClientTree(acceptance.contract, entrypoint, {
368
+ importFileExtension: style.importFileExtension,
369
+ moduleFormat: style.moduleFormat,
370
+ }, REGENERATE));
371
+ const accessVerbs = accessVerbsOf(acceptance.contract).length > 0;
372
+ const decoder = new TextDecoder();
373
+ const tree = emission.tree
374
+ .filter((file) => file.path.startsWith(`${GENERATED_DIRECTORY}/`))
375
+ .map((file) => ({
376
+ file: `${layout.folder}/${file.path}`,
377
+ text: decoder.decode(file.bytes),
378
+ }));
379
+ return {
380
+ files: [
381
+ {
382
+ file: `${layout.folder}/${layout.entry}`,
383
+ text: entrySource(style, accessVerbs),
384
+ },
385
+ {
386
+ file: `${layout.folder}/${layout.universal}`,
387
+ text: universalSource(style, accessVerbs),
388
+ },
389
+ ...tree,
390
+ ],
391
+ tree: `${layout.folder}/${GENERATED_DIRECTORY}`,
392
+ entrypoint: entrypoint.path,
393
+ warnings: emission.warnings,
394
+ };
395
+ }
396
+ function filesUnder(projectDirectory, directory) {
397
+ const root = path.join(projectDirectory, directory);
398
+ try {
399
+ return readdirSync(root, { recursive: true, withFileTypes: true })
400
+ .filter((entry) => entry.isFile())
401
+ .map((entry) => `${directory}/${path
402
+ .relative(root, path.join(entry.parentPath, entry.name))
403
+ .split(path.sep)
404
+ .join("/")}`)
405
+ .sort();
406
+ }
407
+ catch {
408
+ return [];
409
+ }
410
+ }
411
+ function leftovers(projectDirectory, client) {
412
+ const written = new Set(client.files.map(({ file }) => file));
413
+ return filesUnder(projectDirectory, client.tree).filter((file) => !written.has(file) &&
414
+ carriesGeneratedOwnership(readGeneratedFile(projectDirectory, file) ?? ""));
415
+ }
416
+ export function writeBrowserClient(projectDirectory, client) {
417
+ let write = "unchanged";
418
+ for (const { file, text } of client.files) {
419
+ if (writeGeneratedFile(projectDirectory, file, text) === "written") {
420
+ write = "written";
421
+ }
422
+ }
423
+ const removed = leftovers(projectDirectory, client);
424
+ for (const file of removed) {
425
+ rmSync(path.join(projectDirectory, file));
426
+ }
427
+ return { write: removed.length > 0 ? "written" : write, removed };
428
+ }
429
+ export function checkBrowserClient(projectDirectory, client) {
430
+ for (const { file, text } of client.files) {
431
+ const current = readGeneratedFile(projectDirectory, file);
432
+ if (current === undefined) {
433
+ return { file, what: "is missing" };
434
+ }
435
+ if (current !== text) {
436
+ return {
437
+ file,
438
+ what: "is stale: the ClientContract or the project's module settings changed since it was written",
439
+ };
440
+ }
441
+ }
442
+ const [left] = leftovers(projectDirectory, client);
443
+ return left === undefined
444
+ ? undefined
445
+ : {
446
+ file: left,
447
+ what: "is left from an earlier ClientContract's browser client",
448
+ };
449
+ }