@aventara/build 1.0.0-rc.4 → 1.0.0-rc.9
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/CHANGELOG.md +121 -0
- package/README.md +37 -2
- package/dist/cli.js +71 -33
- package/dist/client/client-file.js +328 -0
- package/dist/contracts/contracts-file.js +16 -22
- package/dist/contracts/prebuilt-contracts.compiler.js +8 -1
- package/dist/project/generated-files.resolver.js +6 -3
- package/dist/project/host.catalog.generated.js +32 -0
- package/dist/project/project-layout.resolver.js +59 -0
- package/dist/project/project-modules.loader.js +5 -9
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,126 @@
|
|
|
1
1
|
# @aventara/build
|
|
2
2
|
|
|
3
|
+
## 1.0.0-rc.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 99278e4: `aventara-build` reads a Next.js project's files from `src/aventara/` and, with `--client`, writes its browser client.
|
|
8
|
+
|
|
9
|
+
- A project whose `package.json` depends on `@aventara/next` keeps its configuration and adapter in
|
|
10
|
+
`src/aventara/config.ts` and `src/aventara/adapter.ts`; the build runs them there, and a configuration that imports no
|
|
11
|
+
Resource tree has its generated files in `src/aventara/generated/`. Every other project keeps
|
|
12
|
+
`src/aventara.config.ts` and `src/aventara.adapter.ts`, unchanged. A missing file is named with where the project's
|
|
13
|
+
host keeps it, and a project depending on both `@aventara/nest` and `@aventara/next` is refused in one sentence.
|
|
14
|
+
- `aventara-build --client` also writes the browser client from the ClientContract it has just compiled, with no
|
|
15
|
+
running server: `src/aventara/client.ts` and the client's modules under `src/aventara/generated/`, the same modules
|
|
16
|
+
`avclient generate` writes for that ClientContract except that the entrypoint is the app's own `api` on the page's
|
|
17
|
+
origin (`/api`) and the regenerate instructions. Its imports are spelled as the project's `tsconfig.json` says, and nothing it writes imports
|
|
18
|
+
`@aventara/client`.
|
|
19
|
+
- `src/aventara/client.ts` exports `avClient` and `avHydrate`, and re-exports every name `avclient generate`'s
|
|
20
|
+
`AvClient.ts` exports besides its default (`AvClient`, the Resource types, the enums, `AvDecimal`, the error
|
|
21
|
+
classes), so `import { AvDecimal } from "@/aventara/client"` works. The generated files and the contract-mismatch
|
|
22
|
+
error say to regenerate with `aventara-build --client` (the project's `aventara:prepare` script). `avClient` calls the API from the browser; a call made
|
|
23
|
+
on the server (a Client Component rendering there) rejects with one sentence pointing to `@/aventara/server` and
|
|
24
|
+
`@/aventara/universal`, and sends nothing. `avHydrate` revives data `avDehydrate` sends from the server: tagged
|
|
25
|
+
dates, `bigint`s, byte arrays and decimals become `Date`, `bigint`, `Uint8Array` and the client's own `AvDecimal`,
|
|
26
|
+
for TanStack Query's `deserializeData` or an SWR `fallback`.
|
|
27
|
+
- `aventara-build --check --client` checks those files too: a missing or stale one, or one an earlier ClientContract's
|
|
28
|
+
client left, exits 1 naming it. `--client` is refused in a NestJS project, whose frontends keep using
|
|
29
|
+
`avclient generate`, which is unchanged.
|
|
30
|
+
- `@aventara/client` gains an `@aventara/client/internal` entry, which `@aventara/build` reads the generator through.
|
|
31
|
+
It is not public API.
|
|
32
|
+
|
|
33
|
+
- 8c40b81: `aventara new` and `aventara init` now scaffold Next.js apps.
|
|
34
|
+
|
|
35
|
+
- `aventara new <name> --host next` runs `create-next-app@latest` (TypeScript, the App Router under `src/`, the `@/*`
|
|
36
|
+
alias, no install of its own), then the same `init` steps, then the install. create-next-app asks its own questions
|
|
37
|
+
on your terminal; with `--yes` it takes its defaults. Its own flags go after `--`, as they are:
|
|
38
|
+
`aventara new my-app --host next --yes -- --tailwind`. `--skip-git` becomes its `--disable-git`. Without a terminal,
|
|
39
|
+
`--yes` is required. `--host` defaults to `nest`: a NestJS project is scaffolded exactly as before.
|
|
40
|
+
- `aventara init` tells the host by `package.json`: `@nestjs/core` is NestJS 12, `next` is Next.js 16, and a project
|
|
41
|
+
that depends on both or neither is refused in one sentence, with nothing written. A Next.js app needs `src/app/`
|
|
42
|
+
and the `@/*` → `./src/*` path in `tsconfig.json`, and no ORM yet.
|
|
43
|
+
- In a Next.js app, init writes `src/aventara/config.ts`, `src/aventara/adapter.ts`, `src/aventara/server.ts`
|
|
44
|
+
(`Aventara()`, exporting `handlers`, `avClient`, `avApp` and `ready`), `src/app/api/[...aventara]/route.ts`,
|
|
45
|
+
`src/instrumentation.ts` (awaiting `ready()` on the Node.js runtime) and `src/prisma.ts`, with Prisma's schema and
|
|
46
|
+
config. `aventara:prepare` ends with `aventara-build --client` and runs on `postinstall` and `prebuild`; the files it
|
|
47
|
+
generates (`src/aventara/generated/`, `src/aventara/client.ts`, `src/aventara/universal.ts`) are added to
|
|
48
|
+
`.gitignore`. An instrumentation file of your own is never replaced: the lines to add are printed.
|
|
49
|
+
- Each host package now declares its project layout in its manifest (`"aventara": { "host": … }`). The CLI scaffolds
|
|
50
|
+
from it and `aventara-build` reads its paths from it, so the two always agree.
|
|
51
|
+
|
|
52
|
+
- 33493e4: `@/aventara/universal` and `avDehydrate`: data prefetched on the server keeps its values in the browser.
|
|
53
|
+
|
|
54
|
+
- `aventara-build --client` also writes `src/aventara/universal.ts`. Its `avClient` has the same types as
|
|
55
|
+
`@/aventara/client`'s and runs on both sides: in the browser it calls the app's `/api`; on the server it answers the
|
|
56
|
+
same request in process, with the request's `state`, so a `Date`, a `bigint` or a decimal comes back the same on
|
|
57
|
+
either side. It needs `src/instrumentation.ts` to await `ready()`; on a server where nothing ran it, a call is
|
|
58
|
+
refused with one sentence naming `instrumentation.ts` and `ready()`. Each call first awaits Next's `io()`, as
|
|
59
|
+
`@aventara/next`'s `avClient` does, so under Cache Components a page's call runs at request time, never while
|
|
60
|
+
`next build` prerenders it (the page needs `<Suspense>`, `"use cache"` or `export const instant = false`). Like
|
|
61
|
+
the other generated files, it imports nothing from `@aventara/client` or `@aventara/next` (only `io` from
|
|
62
|
+
`next/cache`), and `aventara-build --check --client` checks it.
|
|
63
|
+
- `ready()` now also registers the in-process path the universal `avClient` takes on the server.
|
|
64
|
+
- `@aventara/next` exports `avDehydrate`. It turns each `Date`, `bigint`, `Uint8Array` and decimal in a result into a
|
|
65
|
+
tagged plain object that React can pass from a Server Component to a Client Component, where `avHydrate` from
|
|
66
|
+
`@/aventara/client` turns it back. Use it as TanStack Query's `dehydrate(queryClient, { serializeData: avDehydrate })`
|
|
67
|
+
with `deserializeData: avHydrate` on the browser's `QueryClient`, or on an SWR `fallback`.
|
|
68
|
+
|
|
69
|
+
- Updated dependencies [99278e4]
|
|
70
|
+
- Updated dependencies [205a379]
|
|
71
|
+
- Updated dependencies [a0bb05e]
|
|
72
|
+
- Updated dependencies [86ab319]
|
|
73
|
+
- Updated dependencies [fdf52e6]
|
|
74
|
+
- @aventara/client@1.0.0-rc.9
|
|
75
|
+
- @aventara/core@1.0.0-rc.9
|
|
76
|
+
|
|
77
|
+
## 1.0.0-rc.8
|
|
78
|
+
|
|
79
|
+
1.0.0-rc.5, rc.6 and rc.7 were tagged but never published; 1.0.0-rc.8 contains everything listed under them and the changes below.
|
|
80
|
+
|
|
81
|
+
### Patch Changes
|
|
82
|
+
|
|
83
|
+
- 1c8e4ee: `aventara-build --check` says what changed when the prebuilt Contracts file is stale, in the same words as the
|
|
84
|
+
`AVENTARA_PREBUILT_STALE` warning at startup. Before, anything outside a single Resource read "an input that belongs
|
|
85
|
+
to no single Resource, the core version or the file format changed". Now it names the versions or the inputs:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
aventara-build: src/generated/aventara/contracts.generated.ts is stale: it was built by @aventara/core 1.0.0-rc.7, and this is 1.0.0-rc.8. Run aventara-build (npm run aventara:prepare) and deploy the file it writes.
|
|
89
|
+
aventara-build: src/generated/aventara/contracts.generated.ts is stale: Resource User changed and the configuration or model outside the Resources changed since it was written. Run aventara-build (npm run aventara:prepare) and deploy the file it writes.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It also names a file format or protocol version that differs, the Resources that are new or gone, and a file that
|
|
93
|
+
no longer reads as a prebuilt Contracts file.
|
|
94
|
+
|
|
95
|
+
- Updated dependencies [1c8e4ee]
|
|
96
|
+
- @aventara/core@1.0.0-rc.8
|
|
97
|
+
|
|
98
|
+
## 1.0.0-rc.7
|
|
99
|
+
|
|
100
|
+
1.0.0-rc.5 and 1.0.0-rc.6 were tagged but never published; 1.0.0-rc.7 contains everything listed under them and the changes below.
|
|
101
|
+
|
|
102
|
+
### Patch Changes
|
|
103
|
+
|
|
104
|
+
- Updated dependencies [f917c1c]
|
|
105
|
+
- @aventara/core@1.0.0-rc.7
|
|
106
|
+
|
|
107
|
+
## 1.0.0-rc.6
|
|
108
|
+
|
|
109
|
+
1.0.0-rc.5 was tagged but never published. 1.0.0-rc.6 contains everything listed under 1.0.0-rc.5 and the fix below.
|
|
110
|
+
|
|
111
|
+
### Patch Changes
|
|
112
|
+
|
|
113
|
+
- Updated dependencies [1cf72e0]
|
|
114
|
+
- @aventara/core@1.0.0-rc.6
|
|
115
|
+
|
|
116
|
+
## 1.0.0-rc.5
|
|
117
|
+
|
|
118
|
+
### Patch Changes
|
|
119
|
+
|
|
120
|
+
- Updated dependencies [4795ff1]
|
|
121
|
+
- Updated dependencies [9e178ca]
|
|
122
|
+
- @aventara/core@1.0.0-rc.5
|
|
123
|
+
|
|
3
124
|
## 1.0.0-rc.4
|
|
4
125
|
|
|
5
126
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -100,15 +100,50 @@ diagnostic is printed with its path and documentation link, nothing is written,
|
|
|
100
100
|
Because the build runs `src/aventara.config.ts`, everything it imports runs at build time too. Keep its top level free of
|
|
101
101
|
side effects (no connection, no request). A value read from `process.env` is read when the build runs.
|
|
102
102
|
|
|
103
|
+
## Next.js projects
|
|
104
|
+
|
|
105
|
+
A project whose `package.json` depends on `@aventara/next` keeps everything Aventara in one folder, `src/aventara/`. The
|
|
106
|
+
build runs `src/aventara/config.ts` and `src/aventara/adapter.ts` there instead of `src/aventara.config.ts` and
|
|
107
|
+
`src/aventara.adapter.ts`, and a configuration that imports no Resource tree has its files in `src/aventara/generated/`.
|
|
108
|
+
Every other project keeps the paths above.
|
|
109
|
+
|
|
110
|
+
### `--client`
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
aventara-build --client
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
In a Next.js project, `--client` also writes the browser client, from the ClientContract the build has just compiled,
|
|
117
|
+
so no running server is needed:
|
|
118
|
+
|
|
119
|
+
| File | What it holds |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `src/aventara/client.ts` | `avClient`, the typed client calling the app's own `api` (`/api`) from the browser, and `avHydrate`; and every name `avclient generate`'s `AvClient.ts` exports besides its default: `AvClient`, the Resource types, the enums, `AvDecimal` and the error classes |
|
|
122
|
+
| `src/aventara/universal.ts` | `avClient` for both sides: in the browser it calls the app's own `api`, on the server it calls the app in process, through what `ready()` from `@aventara/next` registers in `src/instrumentation.ts`, with that request's state |
|
|
123
|
+
| `src/aventara/generated/` (the client's modules) | the same modules `avclient generate` writes for the same ClientContract, but with the app's own `api` as the entrypoint, and saying to regenerate them with `aventara-build --client` |
|
|
124
|
+
|
|
125
|
+
Import it as `@/aventara/client` in Client Components, and `@/aventara/universal` in code that runs on both sides (a
|
|
126
|
+
TanStack Query `queryFn` prefetched on the server, for example). The files import nothing from `@aventara/client` or
|
|
127
|
+
`@aventara/next`, so the app needs no generator at run time. `avClient` calls the API from the browser only: a call made on the server, while a
|
|
128
|
+
Client Component renders there, is refused with a sentence pointing to `@/aventara/server` and `@/aventara/universal`.
|
|
129
|
+
The universal `avClient` answers the same values on both sides; on a server where `ready()` has not run, a call is
|
|
130
|
+
refused with a sentence naming `src/instrumentation.ts` and `ready()`. `avHydrate` revives data `avDehydrate` sent from the server (dates, `bigint`s, byte arrays and decimals, a decimal as
|
|
131
|
+
the client's own `AvDecimal`), for TanStack Query's `deserializeData` or an SWR `fallback`.
|
|
132
|
+
|
|
133
|
+
`avclient generate` stays the way to generate a client for a separate frontend. In a NestJS project `--client` is
|
|
134
|
+
refused.
|
|
135
|
+
|
|
103
136
|
## `--check`
|
|
104
137
|
|
|
105
138
|
```bash
|
|
106
139
|
aventara-build --check
|
|
140
|
+
aventara-build --check --client
|
|
107
141
|
```
|
|
108
142
|
|
|
109
143
|
Writes nothing. It exits 0 when the three files on disk are the ones a build would write now, and 1 on the first that is
|
|
110
|
-
missing or stale, naming it (and, for the Contracts, the Resources whose configuration or model changed).
|
|
111
|
-
|
|
144
|
+
missing or stale, naming it (and, for the Contracts, the Resources whose configuration or model changed). With
|
|
145
|
+
`--client`, the browser client's files are checked too, after the Contracts: a missing or stale file, or one an earlier
|
|
146
|
+
ClientContract's client left, exits 1. Run it in CI before a deploy.
|
|
112
147
|
|
|
113
148
|
## Using the file
|
|
114
149
|
|
package/dist/cli.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { AvBuildError } from "./build.error.js";
|
|
3
|
+
import { checkBrowserClient, emitBrowserClient, writeBrowserClient, } from "./client/client-file.js";
|
|
3
4
|
import { readConfigComputedFields } from "./computed/computed-fields.reader.js";
|
|
4
5
|
import { emitComputedFieldsModule } from "./computed/computed-fields-file.js";
|
|
5
6
|
import { checkContractsFile, writeContractsFile, } from "./contracts/contracts-file.js";
|
|
6
7
|
import { compilePrebuiltContracts } from "./contracts/prebuilt-contracts.compiler.js";
|
|
7
8
|
import { readGeneratedFile, writeGeneratedFile, } from "./project/generated-file.js";
|
|
8
9
|
import { DEFAULT_GENERATED_FILES, resolveGeneratedFiles, } from "./project/generated-files.resolver.js";
|
|
9
|
-
import {
|
|
10
|
+
import { PROJECT_LAYOUTS, resolveProjectLayout, } from "./project/project-layout.resolver.js";
|
|
11
|
+
import { findProjectModules, loadProjectModules, } from "./project/project-modules.loader.js";
|
|
10
12
|
import { importSuffixOf, projectCompilerOptions, } from "./project/tsconfig.reader.js";
|
|
11
13
|
import { RESOURCES_GLOB, readResourceDefinitions, } from "./resources/resource-definitions.reader.js";
|
|
12
14
|
import { emitResourcesModule } from "./resources/resources-file.js";
|
|
13
15
|
const USAGE = `aventara-build — compile this project's Aventara Contracts ahead of time.
|
|
14
16
|
|
|
15
17
|
Usage:
|
|
16
|
-
aventara-build
|
|
17
|
-
aventara-build --
|
|
18
|
+
aventara-build write the three files below
|
|
19
|
+
aventara-build --client also write a Next.js app's browser client
|
|
20
|
+
aventara-build --check [--client] write nothing; exit 1 when one is missing or stale
|
|
18
21
|
|
|
19
22
|
Run it from the project's root. It writes, in this order:
|
|
20
23
|
|
|
@@ -28,36 +31,50 @@ Run it from the project's root. It writes, in this order:
|
|
|
28
31
|
|
|
29
32
|
All three go to the directory the configuration imports \`aventaraResources\`
|
|
30
33
|
from (src/aventara/ when \`aventara init\` found the ORM's client generated into
|
|
31
|
-
src/generated), else to
|
|
34
|
+
src/generated), else to ${PROJECT_LAYOUTS.nest.generated}/ (${PROJECT_LAYOUTS.next.generated}/
|
|
35
|
+
in a Next.js project).
|
|
32
36
|
|
|
33
|
-
It runs
|
|
34
|
-
\`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
It runs the configuration (its \`aventaraConfig\` export or its default,
|
|
38
|
+
without \`adapter\`) and the adapter's description (its default export, such
|
|
39
|
+
as \`describePrismaAdapter({ discovery, provider, driver })\`), in TypeScript
|
|
40
|
+
or JavaScript, and compiles the Contracts with the project's own
|
|
41
|
+
@aventara/core. It never constructs a database client. Where they are depends
|
|
42
|
+
on the project's host:
|
|
43
|
+
|
|
44
|
+
a Next.js project (its package.json depends on @aventara/next)
|
|
45
|
+
${PROJECT_LAYOUTS.next.config}.ts and ${PROJECT_LAYOUTS.next.adapter}.ts
|
|
46
|
+
any other project (NestJS)
|
|
47
|
+
${PROJECT_LAYOUTS.nest.config}.ts and ${PROJECT_LAYOUTS.nest.adapter}.ts
|
|
48
|
+
|
|
49
|
+
With --client, a Next.js project also gets its browser client, written from
|
|
50
|
+
the ClientContract just compiled: ${PROJECT_LAYOUTS.next.client.folder}/${PROJECT_LAYOUTS.next.client.entry}
|
|
51
|
+
(\`avClient\`, calling the app's own \`api\` from the browser, and \`avHydrate\`),
|
|
52
|
+
${PROJECT_LAYOUTS.next.client.folder}/${PROJECT_LAYOUTS.next.client.universal} (\`avClient\` for both sides: the app's \`api\`
|
|
53
|
+
from the browser, the app in process on the server) and the client's modules
|
|
54
|
+
under ${PROJECT_LAYOUTS.next.client.folder}/generated/.
|
|
39
55
|
|
|
40
56
|
A serverless or edge host passes the file's \`aventaraContracts\` to
|
|
41
57
|
createFramework as \`prebuilt\`; a long-running server needs none.
|
|
42
58
|
|
|
43
59
|
Options:
|
|
60
|
+
--client Also write the browser client (Next.js projects).
|
|
44
61
|
--check Exit 1, writing nothing, when a file is missing or stale.
|
|
45
62
|
-h, --help Print this and exit 0.`;
|
|
46
63
|
function parse(argv) {
|
|
47
64
|
let check = false;
|
|
65
|
+
let client = false;
|
|
48
66
|
for (const argument of argv) {
|
|
49
67
|
if (argument === "--check") {
|
|
50
68
|
check = true;
|
|
51
69
|
continue;
|
|
52
70
|
}
|
|
71
|
+
if (argument === "--client") {
|
|
72
|
+
client = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
53
75
|
throw new AvBuildError(`unknown argument \`${argument}\`.\n\n${USAGE}`);
|
|
54
76
|
}
|
|
55
|
-
return { check };
|
|
56
|
-
}
|
|
57
|
-
function listed(names) {
|
|
58
|
-
return names.length < 2
|
|
59
|
-
? names.join("")
|
|
60
|
-
: `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
77
|
+
return { check, client };
|
|
61
78
|
}
|
|
62
79
|
function counted(count) {
|
|
63
80
|
return `${count} Resource${count === 1 ? "" : "s"}`;
|
|
@@ -82,9 +99,14 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
82
99
|
return 0;
|
|
83
100
|
}
|
|
84
101
|
try {
|
|
85
|
-
const { check } = parse(argv);
|
|
86
|
-
const
|
|
87
|
-
const
|
|
102
|
+
const { check, client } = parse(argv);
|
|
103
|
+
const layout = resolveProjectLayout(projectDirectory);
|
|
104
|
+
const clientLayout = client ? layout.client : undefined;
|
|
105
|
+
if (client && clientLayout === undefined) {
|
|
106
|
+
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\`.`);
|
|
107
|
+
}
|
|
108
|
+
const files = findProjectModules(projectDirectory, layout);
|
|
109
|
+
const generated = resolveGeneratedFiles(projectDirectory, files.configFile, layout.generated);
|
|
88
110
|
const options = projectCompilerOptions(projectDirectory);
|
|
89
111
|
const found = readResourceDefinitions(projectDirectory, options);
|
|
90
112
|
const tree = emitResourcesModule(projectDirectory, generated.resources, found.definitions, importSuffixOf(options, path.dirname(path.join(projectDirectory, generated.resources))));
|
|
@@ -133,13 +155,40 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
133
155
|
const project = await loadProjectModules(projectDirectory, files);
|
|
134
156
|
const compiled = await compilePrebuiltContracts(project);
|
|
135
157
|
const resources = Object.keys(compiled.prebuilt.fingerprints.resources).length;
|
|
158
|
+
const browserClient = clientLayout === undefined
|
|
159
|
+
? undefined
|
|
160
|
+
: await emitBrowserClient(projectDirectory, clientLayout, {
|
|
161
|
+
client: compiled.client,
|
|
162
|
+
api: compiled.api,
|
|
163
|
+
canonicalText: (contract) => project.internal.canonicalizeContract(contract),
|
|
164
|
+
});
|
|
136
165
|
if (!check) {
|
|
137
166
|
output.stdout(wrote(writeContractsFile(projectDirectory, generated.contracts, compiled), generated.contracts, counted(resources)));
|
|
167
|
+
if (browserClient !== undefined) {
|
|
168
|
+
for (const warning of browserClient.warnings) {
|
|
169
|
+
output.stdout(`aventara-build: ${warning}`);
|
|
170
|
+
}
|
|
171
|
+
const written = writeBrowserClient(projectDirectory, browserClient);
|
|
172
|
+
for (const file of written.removed) {
|
|
173
|
+
output.stdout(`aventara-build: removed ${file}, which this ClientContract's browser client no longer has.`);
|
|
174
|
+
}
|
|
175
|
+
const [entry, universal] = browserClient.files;
|
|
176
|
+
output.stdout(wrote(written.write, `${entry?.file}, ${universal?.file} and ${browserClient.tree}/`, `the browser client, calling ${browserClient.entrypoint === "" ? "/" : browserClient.entrypoint}`));
|
|
177
|
+
}
|
|
138
178
|
return 0;
|
|
139
179
|
}
|
|
140
|
-
const contracts = await checkContractsFile(projectDirectory, generated.contracts, compiled);
|
|
180
|
+
const contracts = await checkContractsFile(projectDirectory, generated.contracts, compiled, project.internal.prebuiltFileChanges);
|
|
141
181
|
if (contracts.kind === "fresh") {
|
|
142
|
-
|
|
182
|
+
const stale = browserClient === undefined
|
|
183
|
+
? undefined
|
|
184
|
+
: checkBrowserClient(projectDirectory, browserClient);
|
|
185
|
+
if (stale !== undefined) {
|
|
186
|
+
output.stderr(refusal(stale));
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
output.stdout(browserClient === undefined
|
|
190
|
+
? `aventara-build: ${generated.resources}, ${generated.computedFields} and ${generated.contracts} are up to date.`
|
|
191
|
+
: `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.`);
|
|
143
192
|
return 0;
|
|
144
193
|
}
|
|
145
194
|
output.stderr(refusal({
|
|
@@ -147,18 +196,7 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
147
196
|
deploy: true,
|
|
148
197
|
what: contracts.kind === "missing"
|
|
149
198
|
? "is missing"
|
|
150
|
-
: `is stale: ${
|
|
151
|
-
...(contracts.resources.length === 0
|
|
152
|
-
? []
|
|
153
|
-
: [
|
|
154
|
-
`the inputs of ${listed(contracts.resources)} changed since it was written`,
|
|
155
|
-
]),
|
|
156
|
-
...(contracts.shared
|
|
157
|
-
? [
|
|
158
|
-
"an input that belongs to no single Resource, the core version or the file format changed",
|
|
159
|
-
]
|
|
160
|
-
: []),
|
|
161
|
-
].join("; ")}`,
|
|
199
|
+
: `is stale: ${contracts.changes.join("; ")}`,
|
|
162
200
|
}));
|
|
163
201
|
return 1;
|
|
164
202
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AvClientConfigError, AvGeneratedNameError, acceptClientContract, 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) {
|
|
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")}
|
|
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) {
|
|
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 = guardedClient(new AvClient({ fetch: universalFetch as AvFetch }));
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
function refusedAsBuild(step) {
|
|
228
|
+
try {
|
|
229
|
+
return step();
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error instanceof AvClientConfigError ||
|
|
233
|
+
error instanceof AvGeneratedNameError) {
|
|
234
|
+
throw new AvBuildError(`the browser client cannot be written: ${error.message}`, { cause: error });
|
|
235
|
+
}
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export async function emitBrowserClient(projectDirectory, layout, source) {
|
|
240
|
+
const acceptance = await acceptClientContract(JSON.parse(source.canonicalText(source.client)));
|
|
241
|
+
if (!acceptance.accepted) {
|
|
242
|
+
throw new AvBuildError(`the ClientContract this build compiled was refused by the client generator: ${acceptance.message}`);
|
|
243
|
+
}
|
|
244
|
+
const folder = path.join(projectDirectory, layout.folder);
|
|
245
|
+
const style = refusedAsBuild(() => resolveModuleStyle({
|
|
246
|
+
generateAt: folder,
|
|
247
|
+
entryFile: path.join(folder, layout.entry),
|
|
248
|
+
}));
|
|
249
|
+
const entrypoint = refusedAsBuild(() => sameOriginEntrypoint(source.api));
|
|
250
|
+
const emission = refusedAsBuild(() => emitClientTree(acceptance.contract, entrypoint, {
|
|
251
|
+
importFileExtension: style.importFileExtension,
|
|
252
|
+
moduleFormat: style.moduleFormat,
|
|
253
|
+
}, REGENERATE));
|
|
254
|
+
const decoder = new TextDecoder();
|
|
255
|
+
const tree = emission.tree
|
|
256
|
+
.filter((file) => file.path.startsWith(`${GENERATED_DIRECTORY}/`))
|
|
257
|
+
.map((file) => ({
|
|
258
|
+
file: `${layout.folder}/${file.path}`,
|
|
259
|
+
text: decoder.decode(file.bytes),
|
|
260
|
+
}));
|
|
261
|
+
return {
|
|
262
|
+
files: [
|
|
263
|
+
{ file: `${layout.folder}/${layout.entry}`, text: entrySource(style) },
|
|
264
|
+
{
|
|
265
|
+
file: `${layout.folder}/${layout.universal}`,
|
|
266
|
+
text: universalSource(style),
|
|
267
|
+
},
|
|
268
|
+
...tree,
|
|
269
|
+
],
|
|
270
|
+
tree: `${layout.folder}/${GENERATED_DIRECTORY}`,
|
|
271
|
+
entrypoint: entrypoint.path,
|
|
272
|
+
warnings: emission.warnings,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function filesUnder(projectDirectory, directory) {
|
|
276
|
+
const root = path.join(projectDirectory, directory);
|
|
277
|
+
try {
|
|
278
|
+
return readdirSync(root, { recursive: true, withFileTypes: true })
|
|
279
|
+
.filter((entry) => entry.isFile())
|
|
280
|
+
.map((entry) => `${directory}/${path
|
|
281
|
+
.relative(root, path.join(entry.parentPath, entry.name))
|
|
282
|
+
.split(path.sep)
|
|
283
|
+
.join("/")}`)
|
|
284
|
+
.sort();
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function leftovers(projectDirectory, client) {
|
|
291
|
+
const written = new Set(client.files.map(({ file }) => file));
|
|
292
|
+
return filesUnder(projectDirectory, client.tree).filter((file) => !written.has(file) &&
|
|
293
|
+
carriesGeneratedOwnership(readGeneratedFile(projectDirectory, file) ?? ""));
|
|
294
|
+
}
|
|
295
|
+
export function writeBrowserClient(projectDirectory, client) {
|
|
296
|
+
let write = "unchanged";
|
|
297
|
+
for (const { file, text } of client.files) {
|
|
298
|
+
if (writeGeneratedFile(projectDirectory, file, text) === "written") {
|
|
299
|
+
write = "written";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const removed = leftovers(projectDirectory, client);
|
|
303
|
+
for (const file of removed) {
|
|
304
|
+
rmSync(path.join(projectDirectory, file));
|
|
305
|
+
}
|
|
306
|
+
return { write: removed.length > 0 ? "written" : write, removed };
|
|
307
|
+
}
|
|
308
|
+
export function checkBrowserClient(projectDirectory, client) {
|
|
309
|
+
for (const { file, text } of client.files) {
|
|
310
|
+
const current = readGeneratedFile(projectDirectory, file);
|
|
311
|
+
if (current === undefined) {
|
|
312
|
+
return { file, what: "is missing" };
|
|
313
|
+
}
|
|
314
|
+
if (current !== text) {
|
|
315
|
+
return {
|
|
316
|
+
file,
|
|
317
|
+
what: "is stale: the ClientContract or the project's module settings changed since it was written",
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const [left] = leftovers(projectDirectory, client);
|
|
322
|
+
return left === undefined
|
|
323
|
+
? undefined
|
|
324
|
+
: {
|
|
325
|
+
file: left,
|
|
326
|
+
what: "is left from an earlier ClientContract's browser client",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -5,7 +5,7 @@ import { readGeneratedFile, writeGeneratedFile, } from "../project/generated-fil
|
|
|
5
5
|
export function writeContractsFile(projectDirectory, contractsFile, compiled) {
|
|
6
6
|
return writeGeneratedFile(projectDirectory, contractsFile, compiled.source);
|
|
7
7
|
}
|
|
8
|
-
export async function checkContractsFile(projectDirectory, contractsFile, compiled) {
|
|
8
|
+
export async function checkContractsFile(projectDirectory, contractsFile, compiled, changesOf) {
|
|
9
9
|
const file = path.join(projectDirectory, contractsFile);
|
|
10
10
|
const text = readGeneratedFile(projectDirectory, contractsFile);
|
|
11
11
|
if (text === undefined) {
|
|
@@ -15,9 +15,21 @@ export async function checkContractsFile(projectDirectory, contractsFile, compil
|
|
|
15
15
|
return { kind: "fresh" };
|
|
16
16
|
}
|
|
17
17
|
const previous = await previousContracts(projectDirectory, file);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (previous === undefined) {
|
|
19
|
+
return {
|
|
20
|
+
kind: "stale",
|
|
21
|
+
changes: ["it could not be read as a prebuilt Contracts file"],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const changes = changesOf(previous, compiled.prebuilt);
|
|
25
|
+
return {
|
|
26
|
+
kind: "stale",
|
|
27
|
+
changes: changes.length > 0
|
|
28
|
+
? changes
|
|
29
|
+
: [
|
|
30
|
+
"its text is not what this build writes, though it records the same core version, file format and inputs",
|
|
31
|
+
],
|
|
32
|
+
};
|
|
21
33
|
}
|
|
22
34
|
async function previousContracts(projectDirectory, file) {
|
|
23
35
|
const jiti = createJiti(pathToFileURL(path.join(projectDirectory, "package.json")).href, { moduleCache: false, fsCache: false, interopDefault: false });
|
|
@@ -31,21 +43,3 @@ async function previousContracts(projectDirectory, file) {
|
|
|
31
43
|
return undefined;
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
|
-
function difference(previous, current) {
|
|
35
|
-
const before = previous.fingerprints.resources;
|
|
36
|
-
const after = current.fingerprints.resources;
|
|
37
|
-
const resources = [
|
|
38
|
-
...new Set([...Object.keys(before), ...Object.keys(after)]),
|
|
39
|
-
]
|
|
40
|
-
.filter((name) => before[name] !== after[name])
|
|
41
|
-
.sort();
|
|
42
|
-
const shared = previous.fingerprints.shared !== current.fingerprints.shared ||
|
|
43
|
-
previous.core !== current.core ||
|
|
44
|
-
previous.protocol !== current.protocol ||
|
|
45
|
-
previous.format !== current.format;
|
|
46
|
-
return {
|
|
47
|
-
kind: "stale",
|
|
48
|
-
resources,
|
|
49
|
-
shared: shared || resources.length === 0,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
@@ -31,12 +31,14 @@ function diagnosticLines(diagnostics) {
|
|
|
31
31
|
export async function compilePrebuiltContracts(project) {
|
|
32
32
|
const { core, internal, config, description } = project;
|
|
33
33
|
let contracts;
|
|
34
|
+
let api;
|
|
34
35
|
try {
|
|
35
36
|
const framework = await core.createFramework({
|
|
36
37
|
...config,
|
|
37
38
|
adapter: buildTimeAdapter(description),
|
|
38
39
|
});
|
|
39
40
|
contracts = framework.contracts;
|
|
41
|
+
api = framework.api;
|
|
40
42
|
}
|
|
41
43
|
catch (error) {
|
|
42
44
|
if (error instanceof core.AvConstructionError) {
|
|
@@ -45,5 +47,10 @@ export async function compilePrebuiltContracts(project) {
|
|
|
45
47
|
throw error;
|
|
46
48
|
}
|
|
47
49
|
const prebuilt = await internal.prebuiltContractsOf(description.model, config, contracts);
|
|
48
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
prebuilt,
|
|
52
|
+
source: internal.emitPrebuiltContracts(prebuilt),
|
|
53
|
+
client: contracts.client,
|
|
54
|
+
api,
|
|
55
|
+
};
|
|
49
56
|
}
|
|
@@ -28,12 +28,15 @@ function treeSpecifier(source) {
|
|
|
28
28
|
}
|
|
29
29
|
return undefined;
|
|
30
30
|
}
|
|
31
|
-
export function resolveGeneratedFiles(projectDirectory, configFile) {
|
|
31
|
+
export function resolveGeneratedFiles(projectDirectory, configFile, defaultDirectory = DEFAULT_DIRECTORY) {
|
|
32
|
+
const fallback = defaultDirectory === DEFAULT_DIRECTORY
|
|
33
|
+
? DEFAULT_GENERATED_FILES
|
|
34
|
+
: filesIn(defaultDirectory);
|
|
32
35
|
const configPath = path.join(projectDirectory, configFile);
|
|
33
36
|
const source = ts.createSourceFile(configPath, readFileSync(configPath, "utf8"), ts.ScriptTarget.Latest, false);
|
|
34
37
|
const specifier = treeSpecifier(source);
|
|
35
38
|
if (specifier === undefined) {
|
|
36
|
-
return
|
|
39
|
+
return fallback;
|
|
37
40
|
}
|
|
38
41
|
const module = path.resolve(path.dirname(configPath), specifier);
|
|
39
42
|
const directory = path.relative(projectDirectory, path.dirname(module));
|
|
@@ -44,5 +47,5 @@ export function resolveGeneratedFiles(projectDirectory, configFile) {
|
|
|
44
47
|
return inside &&
|
|
45
48
|
path.basename(module).replace(/\.[cm]?[jt]s$/, "") === TREE_MODULE
|
|
46
49
|
? filesIn(directory.split(path.sep).join("/"))
|
|
47
|
-
:
|
|
50
|
+
: fallback;
|
|
48
51
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const HOST_CATALOG = [
|
|
2
|
+
{
|
|
3
|
+
"alias": "nest",
|
|
4
|
+
"label": "NestJS",
|
|
5
|
+
"package": "@aventara/nest",
|
|
6
|
+
"framework": {
|
|
7
|
+
"package": "@nestjs/core",
|
|
8
|
+
"range": "^12.0.0"
|
|
9
|
+
},
|
|
10
|
+
"layout": {
|
|
11
|
+
"config": "src/aventara.config",
|
|
12
|
+
"adapter": "src/aventara.adapter",
|
|
13
|
+
"generated": "src/generated/aventara"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"alias": "next",
|
|
18
|
+
"label": "Next.js",
|
|
19
|
+
"package": "@aventara/next",
|
|
20
|
+
"framework": {
|
|
21
|
+
"package": "next",
|
|
22
|
+
"range": ">=16.4 <17"
|
|
23
|
+
},
|
|
24
|
+
"layout": {
|
|
25
|
+
"config": "src/aventara/config",
|
|
26
|
+
"adapter": "src/aventara/adapter",
|
|
27
|
+
"generated": "src/aventara/generated",
|
|
28
|
+
"client": "src/aventara/client.ts",
|
|
29
|
+
"universal": "src/aventara/universal.ts"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AvBuildError } from "../build.error.js";
|
|
4
|
+
import { HOST_CATALOG } from "./host.catalog.generated.js";
|
|
5
|
+
const FALLBACK_HOST = "nest";
|
|
6
|
+
function clientLayoutOf(entry) {
|
|
7
|
+
const { client, universal } = entry.layout;
|
|
8
|
+
if (client === undefined && universal === undefined) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (client === undefined ||
|
|
12
|
+
universal === undefined ||
|
|
13
|
+
path.posix.dirname(client) !== path.posix.dirname(universal)) {
|
|
14
|
+
throw new AvBuildError(`${entry.package} declares its browser client at ${client ?? "nowhere"} and its universal entry at ${universal ?? "nowhere"}; the build writes both into one folder, so a host declares both, side by side.`);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
folder: path.posix.dirname(client),
|
|
18
|
+
entry: path.posix.basename(client),
|
|
19
|
+
universal: path.posix.basename(universal),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function layoutOf(entry) {
|
|
23
|
+
const client = clientLayoutOf(entry);
|
|
24
|
+
return {
|
|
25
|
+
host: entry.alias,
|
|
26
|
+
described: entry.alias === FALLBACK_HOST
|
|
27
|
+
? `a ${entry.label} project`
|
|
28
|
+
: `a ${entry.label} project (it depends on ${entry.package})`,
|
|
29
|
+
config: entry.layout.config,
|
|
30
|
+
adapter: entry.layout.adapter,
|
|
31
|
+
generated: entry.layout.generated,
|
|
32
|
+
...(client === undefined ? {} : { client }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export const PROJECT_LAYOUTS = Object.fromEntries(HOST_CATALOG.map((entry) => [entry.alias, layoutOf(entry)]));
|
|
36
|
+
const DEPENDENCY_LISTS = ["dependencies", "devDependencies"];
|
|
37
|
+
function hostsOf(projectDirectory) {
|
|
38
|
+
let manifest;
|
|
39
|
+
try {
|
|
40
|
+
manifest = JSON.parse(readFileSync(path.join(projectDirectory, "package.json"), "utf8"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const lists = DEPENDENCY_LISTS.map((list) => typeof manifest === "object" && manifest !== null
|
|
46
|
+
? manifest[list]
|
|
47
|
+
: undefined);
|
|
48
|
+
return HOST_CATALOG.filter((entry) => lists.some((list) => typeof list === "object" &&
|
|
49
|
+
list !== null &&
|
|
50
|
+
Object.hasOwn(list, entry.package)));
|
|
51
|
+
}
|
|
52
|
+
export function resolveProjectLayout(projectDirectory) {
|
|
53
|
+
const hosts = hostsOf(projectDirectory);
|
|
54
|
+
if (hosts.length > 1) {
|
|
55
|
+
throw new AvBuildError(`${projectDirectory}/package.json depends on both ${hosts.map((host) => host.package).join(" and ")}, so it is not clear where the configuration and the adapter are. A project hosts its framework in one of them; remove the other.`);
|
|
56
|
+
}
|
|
57
|
+
const [host] = hosts;
|
|
58
|
+
return PROJECT_LAYOUTS[host === undefined ? FALLBACK_HOST : host.alias];
|
|
59
|
+
}
|
|
@@ -5,18 +5,14 @@ import { createJiti } from "jiti";
|
|
|
5
5
|
import { AvBuildError } from "../build.error.js";
|
|
6
6
|
const EXTENSIONS = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts"];
|
|
7
7
|
const EXTENSIONS_AS_LISTED = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"];
|
|
8
|
-
export const PROJECT_MODULES = {
|
|
9
|
-
config: "src/aventara.config",
|
|
10
|
-
adapter: "src/aventara.adapter",
|
|
11
|
-
};
|
|
12
8
|
const CONFIG_EXPORTS = ["aventaraConfig", "default"];
|
|
13
|
-
function projectFile(projectDirectory, module) {
|
|
9
|
+
function projectFile(projectDirectory, module, layout) {
|
|
14
10
|
const present = EXTENSIONS.map((extension) => `${module}${extension}`).filter((file) => statSync(path.join(projectDirectory, file), {
|
|
15
11
|
throwIfNoEntry: false,
|
|
16
12
|
})?.isFile() === true);
|
|
17
13
|
if (present.length === 0) {
|
|
18
14
|
const [first, ...others] = EXTENSIONS_AS_LISTED;
|
|
19
|
-
throw new AvBuildError(`no ${module}${first}, ${others.slice(0, -1).join(", ")} or ${others.at(-1)} in ${projectDirectory}. Run aventara-build from the project's root (the aventara:prepare script does).`);
|
|
15
|
+
throw new AvBuildError(`no ${module}${first}, ${others.slice(0, -1).join(", ")} or ${others.at(-1)} in ${projectDirectory}, where ${layout.described} keeps it. Run aventara-build from the project's root (the aventara:prepare script does).`);
|
|
20
16
|
}
|
|
21
17
|
if (present.length > 1) {
|
|
22
18
|
throw new AvBuildError(`${present.slice(0, -1).join(", ")} and ${present.at(-1)} are all ${module}; keep one and delete the other${present.length > 2 ? "s" : ""}.`);
|
|
@@ -99,10 +95,10 @@ async function projectCore(jiti) {
|
|
|
99
95
|
throw new AvBuildError(`@aventara/core could not be loaded from the project (${error instanceof Error ? error.message.split("\n", 1)[0] : String(error)}); install it beside @aventara/build.`, { cause: error });
|
|
100
96
|
}
|
|
101
97
|
}
|
|
102
|
-
export function findProjectModules(projectDirectory) {
|
|
98
|
+
export function findProjectModules(projectDirectory, layout) {
|
|
103
99
|
return {
|
|
104
|
-
configFile: projectFile(projectDirectory,
|
|
105
|
-
adapterFile: projectFile(projectDirectory,
|
|
100
|
+
configFile: projectFile(projectDirectory, layout.config, layout),
|
|
101
|
+
adapterFile: projectFile(projectDirectory, layout.adapter, layout),
|
|
106
102
|
};
|
|
107
103
|
}
|
|
108
104
|
export async function loadProjectModules(projectDirectory, { configFile, adapterFile }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aventara/build",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.9",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "Compiles an Aventara application's Contracts at build time, for serverless and edge cold starts.",
|
|
6
6
|
"type": "module",
|
|
@@ -16,21 +16,23 @@
|
|
|
16
16
|
"CHANGELOG.md"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@aventara/client": "1.0.0-rc.9",
|
|
19
20
|
"jiti": "2.7.0"
|
|
20
21
|
},
|
|
21
22
|
"peerDependencies": {
|
|
22
|
-
"@aventara/core": "1.0.0-rc.
|
|
23
|
+
"@aventara/core": "1.0.0-rc.9",
|
|
23
24
|
"typescript": ">=5.7.0 <7"
|
|
24
25
|
},
|
|
25
26
|
"publishConfig": {
|
|
26
27
|
"access": "public"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@aventara/core": "1.0.0-rc.
|
|
30
|
+
"@aventara/core": "1.0.0-rc.9",
|
|
30
31
|
"@types/node": "24.10.1",
|
|
31
32
|
"typescript": "^5.9.2"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
35
|
+
"prebuild": "node ../../scripts/build/host-catalog.generator.ts --out src/project/host.catalog.generated.ts",
|
|
34
36
|
"build": "node ../../scripts/build/build-output.cleaner.ts dist && tsc -p tsconfig.json --removeComments",
|
|
35
37
|
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
36
38
|
"test": "vitest run --typecheck --config ../../vitest.config.mts --root .",
|