@aventara/build 0.0.0-stage → 1.0.0-rc.10

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,375 @@
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: (...args: unknown[]) => unknown): (...args: unknown[]) => unknown {
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
+ /** \`call\`, once \`io()\` lets it run, rejected instead of run when \`refusal()\` names why. */
37
+ function guarded(call: (...args: unknown[]) => unknown): (...args: unknown[]) => unknown {
38
+ return (...args) =>
39
+ ioFirst().then(() => {
40
+ const refused = refusal();
41
+ return refused === undefined ? call(...args) : Promise.reject(new Error(refused));
42
+ });
43
+ }
44
+ `;
45
+ const GUARD_SOURCE = `/** A frozen copy of one Resource's families, or one family's variants, each call guarded. */
46
+ function guardedTree(tree: object): object {
47
+ const copy: Record<string, unknown> = Object.create(Object.getPrototypeOf(tree) as object | null);
48
+ for (const [key, value] of Object.entries(tree)) {
49
+ copy[key] = typeof value === "function" ? guarded(value as (...args: unknown[]) => unknown) : guardedTree(value as object);
50
+ }
51
+ return Object.freeze(copy);
52
+ }
53
+
54
+ /**
55
+ * The client with every call that sends a request guarded; \`tx\` builds
56
+ * deferred operations and sends nothing, so it is kept as it is.
57
+ */
58
+ function guardedClient(client: AvClient): AvClient {
59
+ const copy: AvClient = Object.create(AvClient.prototype) as AvClient;
60
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(client))) {
61
+ const value: unknown = descriptor.value;
62
+ Object.defineProperty(copy, key, {
63
+ ...descriptor,
64
+ value:
65
+ key === "tx"
66
+ ? value
67
+ : typeof value === "function"
68
+ ? guarded(value as (...args: unknown[]) => unknown)
69
+ : guardedTree(value as object),
70
+ });
71
+ }
72
+ return copy;
73
+ }
74
+ `;
75
+ function entrySource(style, accessVerbs) {
76
+ const from = (module) => JSON.stringify(importSpecifier(module, style));
77
+ const { tagKey, valueKey, objectTag, scalarTags } = HYDRATION_FORMAT;
78
+ const tagged = [...scalarTags, objectTag]
79
+ .map((tag) => JSON.stringify(tag))
80
+ .join(" | ");
81
+ return `${ENTRY_BANNER}
82
+
83
+ import { AvClient } from ${from(`./${GENERATED_DIRECTORY}/client`)};
84
+ import { type AvBuiltInScalar, decodeScalar } from ${from(`./${GENERATED_DIRECTORY}/runtime/codec`)};
85
+
86
+ ${clientEntryReExports(style, "type", accessVerbs)}
87
+ /** What a call made on the server is refused with. */
88
+ const SERVER_CALL = ${JSON.stringify(SERVER_CALL_REFUSAL)};
89
+
90
+ /**
91
+ * Whether this code runs where the client's entrypoint, a path on the page's own
92
+ * origin, can be resolved: a page or a worker has a location, a server has none.
93
+ */
94
+ function onServer(): boolean {
95
+ return (globalThis as { readonly location?: unknown }).location === undefined;
96
+ }
97
+
98
+ /** Why a call made here and now is refused; \`undefined\` when it may run. */
99
+ function refusal(): string | undefined {
100
+ return onServer() ? SERVER_CALL : undefined;
101
+ }
102
+
103
+ ${BROWSER_GUARD_SOURCE}
104
+ ${GUARD_SOURCE}
105
+ /**
106
+ * This app's client, calling its own origin's API from the browser. A call made
107
+ * on the server (while a Client Component renders there) rejects instead: on the
108
+ * server, import \`avClient\` from \`@/aventara/server\` or \`@/aventara/universal\`.
109
+ */
110
+ export const avClient: AvClient = guardedClient(new AvClient());
111
+
112
+ /** The key a dehydrated value's tag is under: \`{ ${JSON.stringify(tagKey)}: <scalar>, ${JSON.stringify(valueKey)}: <its wire form> }\`. */
113
+ const TAG = ${JSON.stringify(tagKey)};
114
+
115
+ /** The key a tagged value's wire form is under. */
116
+ const VALUE = ${JSON.stringify(valueKey)};
117
+
118
+ /** The tag of a plain object holding a ${JSON.stringify(tagKey)} key of its own, kept as data. */
119
+ const OBJECT_TAG = ${JSON.stringify(objectTag)};
120
+
121
+ /** The scalars a value may be tagged as. */
122
+ const TAGGED_SCALARS: ReadonlySet<string> = new Set<AvBuiltInScalar>([${scalarTags.map((tag) => JSON.stringify(tag)).join(", ")}]);
123
+
124
+ function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
125
+ if (typeof value !== "object" || value === null) {
126
+ return false;
127
+ }
128
+ const prototype: unknown = Object.getPrototypeOf(value);
129
+ return prototype === Object.prototype || prototype === null;
130
+ }
131
+
132
+ function revivedMembers(record: Readonly<Record<string, unknown>>): Record<string, unknown> {
133
+ return Object.fromEntries(Object.entries(record).map(([key, member]) => [key, avHydrate(member)]));
134
+ }
135
+
136
+ /**
137
+ * Revives data that \`avDehydrate\` (from \`@aventara/next\`) made safe to pass
138
+ * from the server to the browser: each tagged Date, bigint, Uint8Array and
139
+ * decimal becomes the value this client reads, a decimal this client's own
140
+ * AvDecimal.
141
+ * Arrays and plain objects are copied with their members revived; any other
142
+ * value is returned as it is. Give it to TanStack Query as \`deserializeData\`,
143
+ * or call it on an SWR \`fallback\`.
144
+ *
145
+ * @throws TypeError when a tagged value is not one avDehydrate writes.
146
+ */
147
+ export function avHydrate(dehydrated: unknown): unknown {
148
+ if (Array.isArray(dehydrated)) {
149
+ return dehydrated.map((member: unknown) => avHydrate(member));
150
+ }
151
+ if (!isPlainObject(dehydrated)) {
152
+ return dehydrated;
153
+ }
154
+ if (!Object.hasOwn(dehydrated, TAG)) {
155
+ return revivedMembers(dehydrated);
156
+ }
157
+ const tag = dehydrated[TAG];
158
+ const wire = dehydrated[VALUE];
159
+ if (Object.keys(dehydrated).length === 2 && Object.hasOwn(dehydrated, VALUE)) {
160
+ if (tag === OBJECT_TAG && isPlainObject(wire)) {
161
+ return revivedMembers(wire);
162
+ }
163
+ if (typeof tag === "string" && TAGGED_SCALARS.has(tag)) {
164
+ return decodeScalar(tag as AvBuiltInScalar, wire);
165
+ }
166
+ }
167
+ throw new TypeError(
168
+ ${JSON.stringify(`A value tagged ${JSON.stringify(tagKey)} must be { ${JSON.stringify(tagKey)}: ${tagged}, ${JSON.stringify(valueKey)}: its value }, as avDehydrate writes it.`)},
169
+ );
170
+ }
171
+ `;
172
+ }
173
+ export const UNIVERSAL_PATH_KEY = "aventara.next.universal";
174
+ 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.';
175
+ function universalSource(style, accessVerbs) {
176
+ const from = (module) => JSON.stringify(importSpecifier(module, style));
177
+ return `${ENTRY_BANNER}
178
+
179
+ import { io } from ${JSON.stringify(style.importFileExtension === "" ? "next/cache" : "next/cache.js")};
180
+ import { AvClient } from ${from(`./${GENERATED_DIRECTORY}/client`)};
181
+ import type { AvFetch } from ${from(`./${GENERATED_DIRECTORY}/runtime/transport`)};
182
+
183
+ /** The key the server's in-process path is registered under, on globalThis, by ready() from @aventara/next. */
184
+ const SERVER_PATH = Symbol.for(${JSON.stringify(UNIVERSAL_PATH_KEY)});
185
+
186
+ /** What a call made on a server where nothing is registered is refused with. */
187
+ const UNREGISTERED = ${JSON.stringify(UNREGISTERED_REFUSAL)};
188
+
189
+ /** Whether this code runs on the server: a page or a worker has a location, a server has none. */
190
+ function onServer(): boolean {
191
+ return (globalThis as { readonly location?: unknown }).location === undefined;
192
+ }
193
+
194
+ /** The in-process path ready() registered on this server, or undefined. */
195
+ function serverPath(): AvFetch | undefined {
196
+ const registered = (globalThis as { readonly [key: symbol]: unknown })[SERVER_PATH];
197
+ return typeof registered === "function" ? (registered as AvFetch) : undefined;
198
+ }
199
+
200
+ /** Why a call made here and now is refused; \`undefined\` when it may run. */
201
+ function refusal(): string | undefined {
202
+ return onServer() && serverPath() === undefined ? UNREGISTERED : undefined;
203
+ }
204
+
205
+ /** In the browser, the platform's fetch; on the server, the in-process path. */
206
+ function universalFetch(...request: unknown[]): unknown {
207
+ const reach = onServer() ? serverPath() : (globalThis as { readonly fetch?: AvFetch }).fetch;
208
+ if (reach === undefined) {
209
+ throw new TypeError(onServer() ? UNREGISTERED : "No fetch is available here.");
210
+ }
211
+ return (reach as (...request: unknown[]) => unknown)(...request);
212
+ }
213
+
214
+ ${UNIVERSAL_GUARD_SOURCE}
215
+ ${GUARD_SOURCE}
216
+ /**
217
+ * This app's client, on the server and in the browser alike: in the browser it
218
+ * calls the app's own origin's API; on the server it calls the app in process,
219
+ * with the request's state, through what \`ready()\` registered at startup
220
+ * (\`src/instrumentation.ts\`). Its values are the same on both sides, so data
221
+ * prefetched on the server hydrates in the browser: pass it through
222
+ * \`avDehydrate\` (\`@aventara/next\`) and \`avHydrate\` (\`@/aventara/client\`).
223
+ */
224
+ export const avClient: AvClient = ${accessVerbs ? "withServerAccess(" : ""}guardedClient(new AvClient({ fetch: universalFetch as AvFetch }))${accessVerbs ? ")" : ""};
225
+ ${accessVerbs ? SERVER_ACCESS_SOURCE : ""}`;
226
+ }
227
+ const UNIVERSAL_ACCESS_KEY = "aventara.next.universal.access";
228
+ const SERVER_ACCESS_SOURCE = `
229
+ /** The key ready() registers avClient.$access under, on globalThis, for the Access verbs on the server. */
230
+ const SERVER_ACCESS = Symbol.for(${JSON.stringify(UNIVERSAL_ACCESS_KEY)});
231
+
232
+ type AccessVerb = (...args: unknown[]) => unknown;
233
+
234
+ /** \`$access\`: \`can\` as it is; each Access verb, on the server, through what ready() registered. */
235
+ function serverAccess(verbs: Readonly<Record<string, AccessVerb>>): object {
236
+ const routed: Record<string, AccessVerb> = {};
237
+ for (const [name, verb] of Object.entries(verbs)) {
238
+ routed[name] =
239
+ name === "can"
240
+ ? verb
241
+ : (...args) => {
242
+ if (!onServer()) {
243
+ return verb(...args);
244
+ }
245
+ const registered = (globalThis as { readonly [key: symbol]: unknown })[SERVER_ACCESS] as
246
+ | Readonly<Record<string, unknown>>
247
+ | undefined;
248
+ const server = registered?.[name];
249
+ return typeof server === "function"
250
+ ? ioFirst().then(() => (server as AccessVerb)(...args))
251
+ : Promise.reject(new Error(UNREGISTERED));
252
+ };
253
+ }
254
+ return Object.freeze(routed);
255
+ }
256
+
257
+ /** The client with its \`$access\` routed by \`serverAccess\`. */
258
+ function withServerAccess(client: AvClient): AvClient {
259
+ const copy: AvClient = Object.create(AvClient.prototype) as AvClient;
260
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(client))) {
261
+ Object.defineProperty(
262
+ copy,
263
+ key,
264
+ key === "$access" ? { ...descriptor, value: serverAccess(descriptor.value as Readonly<Record<string, AccessVerb>>) } : descriptor,
265
+ );
266
+ }
267
+ return copy;
268
+ }
269
+ `;
270
+ function refusedAsBuild(step) {
271
+ try {
272
+ return step();
273
+ }
274
+ catch (error) {
275
+ if (error instanceof AvClientConfigError ||
276
+ error instanceof AvGeneratedNameError) {
277
+ throw new AvBuildError(`the browser client cannot be written: ${error.message}`, { cause: error });
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+ export async function emitBrowserClient(projectDirectory, layout, source) {
283
+ const acceptance = await acceptClientContract(JSON.parse(source.canonicalText(source.client)));
284
+ if (!acceptance.accepted) {
285
+ throw new AvBuildError(`the ClientContract this build compiled was refused by the client generator: ${acceptance.message}`);
286
+ }
287
+ const folder = path.join(projectDirectory, layout.folder);
288
+ const style = refusedAsBuild(() => resolveModuleStyle({
289
+ generateAt: folder,
290
+ entryFile: path.join(folder, layout.entry),
291
+ }));
292
+ const entrypoint = refusedAsBuild(() => sameOriginEntrypoint(source.api));
293
+ const emission = refusedAsBuild(() => emitClientTree(acceptance.contract, entrypoint, {
294
+ importFileExtension: style.importFileExtension,
295
+ moduleFormat: style.moduleFormat,
296
+ }, REGENERATE));
297
+ const accessVerbs = accessVerbsOf(acceptance.contract).length > 0;
298
+ const decoder = new TextDecoder();
299
+ const tree = emission.tree
300
+ .filter((file) => file.path.startsWith(`${GENERATED_DIRECTORY}/`))
301
+ .map((file) => ({
302
+ file: `${layout.folder}/${file.path}`,
303
+ text: decoder.decode(file.bytes),
304
+ }));
305
+ return {
306
+ files: [
307
+ {
308
+ file: `${layout.folder}/${layout.entry}`,
309
+ text: entrySource(style, accessVerbs),
310
+ },
311
+ {
312
+ file: `${layout.folder}/${layout.universal}`,
313
+ text: universalSource(style, accessVerbs),
314
+ },
315
+ ...tree,
316
+ ],
317
+ tree: `${layout.folder}/${GENERATED_DIRECTORY}`,
318
+ entrypoint: entrypoint.path,
319
+ warnings: emission.warnings,
320
+ };
321
+ }
322
+ function filesUnder(projectDirectory, directory) {
323
+ const root = path.join(projectDirectory, directory);
324
+ try {
325
+ return readdirSync(root, { recursive: true, withFileTypes: true })
326
+ .filter((entry) => entry.isFile())
327
+ .map((entry) => `${directory}/${path
328
+ .relative(root, path.join(entry.parentPath, entry.name))
329
+ .split(path.sep)
330
+ .join("/")}`)
331
+ .sort();
332
+ }
333
+ catch {
334
+ return [];
335
+ }
336
+ }
337
+ function leftovers(projectDirectory, client) {
338
+ const written = new Set(client.files.map(({ file }) => file));
339
+ return filesUnder(projectDirectory, client.tree).filter((file) => !written.has(file) &&
340
+ carriesGeneratedOwnership(readGeneratedFile(projectDirectory, file) ?? ""));
341
+ }
342
+ export function writeBrowserClient(projectDirectory, client) {
343
+ let write = "unchanged";
344
+ for (const { file, text } of client.files) {
345
+ if (writeGeneratedFile(projectDirectory, file, text) === "written") {
346
+ write = "written";
347
+ }
348
+ }
349
+ const removed = leftovers(projectDirectory, client);
350
+ for (const file of removed) {
351
+ rmSync(path.join(projectDirectory, file));
352
+ }
353
+ return { write: removed.length > 0 ? "written" : write, removed };
354
+ }
355
+ export function checkBrowserClient(projectDirectory, client) {
356
+ for (const { file, text } of client.files) {
357
+ const current = readGeneratedFile(projectDirectory, file);
358
+ if (current === undefined) {
359
+ return { file, what: "is missing" };
360
+ }
361
+ if (current !== text) {
362
+ return {
363
+ file,
364
+ what: "is stale: the ClientContract or the project's module settings changed since it was written",
365
+ };
366
+ }
367
+ }
368
+ const [left] = leftovers(projectDirectory, client);
369
+ return left === undefined
370
+ ? undefined
371
+ : {
372
+ file: left,
373
+ what: "is left from an earlier ClientContract's browser client",
374
+ };
375
+ }
@@ -0,0 +1,37 @@
1
+ function literal(computedFields) {
2
+ const ordered = {
3
+ agent: computedFields.agent,
4
+ agentExtends: computedFields.agentExtends,
5
+ application: computedFields.application,
6
+ client: computedFields.client,
7
+ root: computedFields.root,
8
+ };
9
+ return JSON.stringify(ordered, null, "\t");
10
+ }
11
+ export function emitComputedFieldsModule(configFile, reading) {
12
+ const header = `// Generated by aventara-build from the type of ${configFile}. Do not edit: regenerate it (aventara:prepare).`;
13
+ const type = [
14
+ "",
15
+ "// What an adapter's discovery artifact carries on its model's type for the stages that run before compute.",
16
+ "export type AvComputedFields = typeof computedFields;",
17
+ ];
18
+ if (reading.kind === "unread") {
19
+ return `${[
20
+ header,
21
+ "",
22
+ `// Not read: ${reading.reason.replaceAll("\n", " ")}. The stages that run before compute may leave any`,
23
+ "// create field out; write the configuration `as const` there to have it read.",
24
+ "export const computedFields = null;",
25
+ ...type,
26
+ ].join("\n")}\n`;
27
+ }
28
+ return `${[
29
+ header,
30
+ 'import type { AvConfigComputedFields } from "@aventara/core";',
31
+ "",
32
+ "// Which fields the configuration's write compute fills (`compute.create`, `compute.update`), per layer and",
33
+ "// Resource. The framework compares it with the configuration at startup and warns when they differ.",
34
+ `export const computedFields = ${literal(reading.computedFields)} as const satisfies AvConfigComputedFields;`,
35
+ ...type,
36
+ ].join("\n")}\n`;
37
+ }