@aventara/next 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/CHANGELOG.md +161 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +352 -2
- package/dist/aventara.factory.d.ts +160 -0
- package/dist/aventara.factory.js +61 -0
- package/dist/host-bootstrap.validator.d.ts +49 -0
- package/dist/host-bootstrap.validator.js +73 -0
- package/dist/hydration-tag.format.d.ts +11 -0
- package/dist/hydration-tag.format.js +6 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/mcp-binding.loader.d.ts +33 -0
- package/dist/mcp-binding.loader.js +39 -0
- package/dist/protocol-route.table.d.ts +25 -0
- package/dist/protocol-route.table.js +13 -0
- package/dist/request-body.reader.d.ts +35 -0
- package/dist/request-body.reader.js +130 -0
- package/dist/request-state.cache.d.ts +9 -0
- package/dist/request-state.cache.js +13 -0
- package/dist/route-handlers.factory.d.ts +60 -0
- package/dist/route-handlers.factory.js +69 -0
- package/dist/server-operations.factory.d.ts +40 -0
- package/dist/server-operations.factory.js +191 -0
- package/dist/shared-framework.registry.d.ts +25 -0
- package/dist/shared-framework.registry.js +75 -0
- package/dist/universal-path.registry.d.ts +28 -0
- package/dist/universal-path.registry.js +55 -0
- package/dist/value.dehydrator.d.ts +14 -0
- package/dist/value.dehydrator.js +72 -0
- package/dist/web-exchange.translator.d.ts +23 -0
- package/dist/web-exchange.translator.js +61 -0
- package/package.json +73 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { holdRequestPrincipal } from "@aventara/core/internal";
|
|
2
|
+
import { headers } from "next/headers.js";
|
|
3
|
+
import { cache } from "react";
|
|
4
|
+
export function createRequestState(state) {
|
|
5
|
+
if (state === undefined) {
|
|
6
|
+
return async () => undefined;
|
|
7
|
+
}
|
|
8
|
+
return cache(async () => {
|
|
9
|
+
const seeded = await state({ headers: await headers() });
|
|
10
|
+
holdRequestPrincipal(seeded);
|
|
11
|
+
return seeded;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AvFramework, AvState } from "@aventara/core";
|
|
2
|
+
import { type AvProtocolBinding } from "@aventara/core/protocol";
|
|
3
|
+
import type { McpBinding } from "./mcp-binding.loader.js";
|
|
4
|
+
import { type McpRouteAnswer, type RouteAnswer } from "./protocol-route.table.js";
|
|
5
|
+
/** What `Aventara()`'s `state` option is handed for one request: `{ headers, request? }`. */
|
|
6
|
+
export type AvNextStateContext = {
|
|
7
|
+
/** The request's headers. */
|
|
8
|
+
readonly headers: Headers;
|
|
9
|
+
/** The route handler's request: present on every call through `handlers`. */
|
|
10
|
+
readonly request?: Request;
|
|
11
|
+
};
|
|
12
|
+
/** The developer's `state` option: seeds a request's `ctx.$state`. */
|
|
13
|
+
export type HostState = (context: AvNextStateContext) => AvState | Promise<AvState>;
|
|
14
|
+
/** A Next.js App Router route handler. */
|
|
15
|
+
export type RouteHandler = (request: Request) => Promise<Response>;
|
|
16
|
+
/**
|
|
17
|
+
* `handlers`, from `Aventara()`: the route handlers the catch-all route file
|
|
18
|
+
* under the configured `api` path exports,
|
|
19
|
+
* `export const { GET, POST, PUT, PATCH, DELETE } = handlers;`.
|
|
20
|
+
*/
|
|
21
|
+
export type AvNextHandlers = {
|
|
22
|
+
readonly GET: RouteHandler;
|
|
23
|
+
readonly POST: RouteHandler;
|
|
24
|
+
readonly PUT: RouteHandler;
|
|
25
|
+
readonly PATCH: RouteHandler;
|
|
26
|
+
readonly DELETE: RouteHandler;
|
|
27
|
+
};
|
|
28
|
+
/** The protocol bound to one Framework, with its routes keyed for dispatch. */
|
|
29
|
+
export type ProtocolHost = {
|
|
30
|
+
readonly binding: AvProtocolBinding;
|
|
31
|
+
/** The Framework's normalized mount path (`""` or `/seg(/seg)*`). */
|
|
32
|
+
readonly api: string;
|
|
33
|
+
readonly maxRequestBytes: number;
|
|
34
|
+
/** Each `surface()` route's answer, keyed by `METHOD path`. */
|
|
35
|
+
readonly routes: ReadonlyMap<string, RouteAnswer>;
|
|
36
|
+
/** The MCP binding's routes when the configuration has `mcp`. */
|
|
37
|
+
readonly mcp: McpHost | undefined;
|
|
38
|
+
};
|
|
39
|
+
/** The MCP binding's routes, keyed for dispatch. */
|
|
40
|
+
type McpHost = {
|
|
41
|
+
readonly maxRequestBytes: number;
|
|
42
|
+
/** Each MCP route's answer, keyed by its absolute path. */
|
|
43
|
+
readonly routes: ReadonlyMap<string, McpRouteAnswer>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Binds `framework`'s protocol once: every route `surface()` lists, keyed by
|
|
47
|
+
* method and path, and the MCP binding's routes, when there is one, by path.
|
|
48
|
+
*/
|
|
49
|
+
export declare function bindProtocolHost(framework: AvFramework, mcp: McpBinding | undefined): ProtocolHost;
|
|
50
|
+
/** A route's dispatch key: its method and its path under the mount path. */
|
|
51
|
+
export declare const routeKey: (method: string, path: string) => string;
|
|
52
|
+
/**
|
|
53
|
+
* The route handlers over the protocol `host` resolves to. A request for one of
|
|
54
|
+
* the MCP binding's paths is answered by that binding; a request whose method
|
|
55
|
+
* and path are a route of `surface()` by its bound member; any other is the
|
|
56
|
+
* protocol's absence answer, or, for a path outside the protocol's namespace,
|
|
57
|
+
* Next's `notFound()`. Nothing is built until the first request.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createRouteHandlers(host: () => Promise<ProtocolHost>, state: HostState | undefined): AvNextHandlers;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AvProtocol } from "@aventara/core/protocol";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { MCP_ROUTES, PROTOCOL_ROUTES, } from "./protocol-route.table.js";
|
|
4
|
+
import { readRawBody, readRequestBody, unreadBodyAnswer, } from "./request-body.reader.js";
|
|
5
|
+
import { mcpRequest, NO_BYTES, pathnameOf, protocolRequest, webResponse, } from "./web-exchange.translator.js";
|
|
6
|
+
export function bindProtocolHost(framework, mcp) {
|
|
7
|
+
const binding = AvProtocol.bind(framework);
|
|
8
|
+
return {
|
|
9
|
+
mcp: mcp === undefined
|
|
10
|
+
? undefined
|
|
11
|
+
: {
|
|
12
|
+
maxRequestBytes: mcp.maxRequestBytes,
|
|
13
|
+
routes: new Map(mcp
|
|
14
|
+
.surface()
|
|
15
|
+
.map((route) => [route.path, MCP_ROUTES[route.kind](mcp)])),
|
|
16
|
+
},
|
|
17
|
+
binding,
|
|
18
|
+
api: framework.api,
|
|
19
|
+
maxRequestBytes: framework.contracts.client.limits.maxRequestBytes,
|
|
20
|
+
routes: new Map(binding
|
|
21
|
+
.surface()
|
|
22
|
+
.map((route) => [
|
|
23
|
+
routeKey(route.method, route.path),
|
|
24
|
+
PROTOCOL_ROUTES[route.kind](binding),
|
|
25
|
+
])),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export const routeKey = (method, path) => `${method} ${path}`;
|
|
29
|
+
function outsideApi(pathname, api) {
|
|
30
|
+
return `Aventara: a request for ${pathname} reached handlers, which serve the configured \`api\` path ${api === "" ? "/" : api}; put the route file at app${api}/[...aventara]/route.ts.`;
|
|
31
|
+
}
|
|
32
|
+
export function createRouteHandlers(host, state) {
|
|
33
|
+
const handle = async (request) => {
|
|
34
|
+
const pathname = pathnameOf(request);
|
|
35
|
+
const { binding, api, maxRequestBytes, routes, mcp } = await host();
|
|
36
|
+
const seed = state === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: () => state({ headers: request.headers, request });
|
|
39
|
+
const mcpAnswer = mcp?.routes.get(pathname);
|
|
40
|
+
if (mcp !== undefined && mcpAnswer !== undefined) {
|
|
41
|
+
const body = await readRawBody(request, mcp.maxRequestBytes);
|
|
42
|
+
return webResponse(await mcpAnswer(mcpRequest(request, body.content), seed));
|
|
43
|
+
}
|
|
44
|
+
if (pathname !== api && !pathname.startsWith(`${api}/`)) {
|
|
45
|
+
throw new Error(outsideApi(pathname, api));
|
|
46
|
+
}
|
|
47
|
+
const path = pathname.slice(api.length);
|
|
48
|
+
const answer = routes.get(routeKey(request.method, path));
|
|
49
|
+
if (answer === undefined) {
|
|
50
|
+
const absent = binding.answerAbsent(protocolRequest(request, path, NO_BYTES));
|
|
51
|
+
if (absent === undefined) {
|
|
52
|
+
notFound();
|
|
53
|
+
}
|
|
54
|
+
return webResponse(absent);
|
|
55
|
+
}
|
|
56
|
+
const body = await readRequestBody(request, maxRequestBytes);
|
|
57
|
+
if (body.kind !== "read") {
|
|
58
|
+
return webResponse(unreadBodyAnswer(binding, body));
|
|
59
|
+
}
|
|
60
|
+
return webResponse(await answer(protocolRequest(request, path, body.content), seed, request.signal));
|
|
61
|
+
};
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
GET: handle,
|
|
64
|
+
POST: handle,
|
|
65
|
+
PUT: handle,
|
|
66
|
+
PATCH: handle,
|
|
67
|
+
DELETE: handle,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type AvExecutionOptions, type AvFramework, type AvOperation, type AvState, type AvTransactionResults } from "@aventara/core";
|
|
2
|
+
import type { AccessClient } from "./host-bootstrap.validator.js";
|
|
3
|
+
/**
|
|
4
|
+
* `avClient.transaction`: runs `avClient.tx` handles as one transaction, with
|
|
5
|
+
* this request's state. Client-layer handles only: an `avApp.appTx` handle in
|
|
6
|
+
* the list is a type error, and refused at runtime (`A2004`/`V1020`).
|
|
7
|
+
*/
|
|
8
|
+
export type AvNextClientTransaction = <const Ops extends readonly AvOperation<unknown, "client">[]>(operations: Ops, options?: AvExecutionOptions) => Promise<AvTransactionResults<Ops>>;
|
|
9
|
+
/**
|
|
10
|
+
* `avApp.transaction`: runs `avApp.appTx` handles as one transaction.
|
|
11
|
+
* Application handles only: an `avClient.tx` handle in the list is a type
|
|
12
|
+
* error, and refused at runtime (`A2004`/`V1020`).
|
|
13
|
+
*/
|
|
14
|
+
export type AvNextAppTransaction = <const Ops extends readonly AvOperation<unknown, "application">[]>(operations: Ops, options?: AvExecutionOptions) => Promise<AvTransactionResults<Ops>>;
|
|
15
|
+
/** Where the members find their Framework: built on first use, once. */
|
|
16
|
+
type FrameworkSource = {
|
|
17
|
+
/** The Framework, built on the first call; a construction error is kept. */
|
|
18
|
+
readonly built: () => Promise<AvFramework>;
|
|
19
|
+
/** The Framework when it is already built, else `undefined`. */
|
|
20
|
+
readonly current: () => AvFramework | undefined;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Refuses a Framework one of whose Resources, in the Contract of the layer
|
|
24
|
+
* `avClient` or `avApp` serves (client, application), is named like that
|
|
25
|
+
* tree's own member (`tx`, `transaction`, `appTx`).
|
|
26
|
+
*
|
|
27
|
+
* @throws {@link AvConstructionError} naming each clash.
|
|
28
|
+
*/
|
|
29
|
+
export declare function refuseMemberClashes(framework: AvFramework): void;
|
|
30
|
+
/**
|
|
31
|
+
* `avClient` and `avApp` over the Framework `source` builds. Each call awaits
|
|
32
|
+
* the Framework, then runs the Framework's own member: `avClient`'s with the
|
|
33
|
+
* request's state from `requestState` (a `state` the call names wins),
|
|
34
|
+
* `avApp`'s as given.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createServerOperations(source: FrameworkSource, requestState: () => Promise<AvState | undefined>, access: AccessClient | undefined): {
|
|
37
|
+
readonly avClient: object;
|
|
38
|
+
readonly avApp: object;
|
|
39
|
+
};
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { AvConstructionError, AvProtocolError, } from "@aventara/core";
|
|
2
|
+
import { ACCESS_VERB_NAMES, ACCESS_VERBS, providerVerb, } from "@aventara/core/internal";
|
|
3
|
+
import { io } from "next/cache.js";
|
|
4
|
+
import { headers } from "next/headers.js";
|
|
5
|
+
function ioBeforeCall() {
|
|
6
|
+
try {
|
|
7
|
+
return io();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return Promise.resolve();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const handlesBeforeBuild = (member) => `Aventara: \`${member}\` builds its handles on the Framework, which is built on first use; \`await ready()\` before building them.`;
|
|
14
|
+
const CLIENT_MEMBERS = ["all", "tx", "transaction", "$access"];
|
|
15
|
+
const APPLICATION_MEMBERS = ["appTx", "transaction"];
|
|
16
|
+
export function refuseMemberClashes(framework) {
|
|
17
|
+
const clashes = [
|
|
18
|
+
...clashesOf("avClient", framework.contracts.client.resources, CLIENT_MEMBERS),
|
|
19
|
+
...clashesOf("avApp", framework.contracts.application.resources, APPLICATION_MEMBERS),
|
|
20
|
+
];
|
|
21
|
+
if (clashes.length > 0) {
|
|
22
|
+
throw new AvConstructionError("contract-compilation", clashes.map(({ tree, member }) => ({
|
|
23
|
+
code: "COMPILER_RESERVED_RESOURCE_NAME",
|
|
24
|
+
path: `adapter.model.resources.${member}`,
|
|
25
|
+
message: `resource "${member}" clashes with ${tree}.${member}; rename the Resource or expose it under another name`,
|
|
26
|
+
})));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function clashesOf(tree, resources, members) {
|
|
30
|
+
return members
|
|
31
|
+
.filter((member) => Object.hasOwn(resources, member))
|
|
32
|
+
.map((member) => ({ tree, member }));
|
|
33
|
+
}
|
|
34
|
+
export function createServerOperations(source, requestState, access) {
|
|
35
|
+
const withRequestState = async (options) => {
|
|
36
|
+
if (options?.state !== undefined) {
|
|
37
|
+
return options;
|
|
38
|
+
}
|
|
39
|
+
const state = await requestState();
|
|
40
|
+
return state === undefined ? options : { ...options, state };
|
|
41
|
+
};
|
|
42
|
+
const asGiven = async (options) => options;
|
|
43
|
+
const clientTransaction = transactionOf(source, withRequestState);
|
|
44
|
+
const all = allOf(source);
|
|
45
|
+
const applicationTransaction = transactionOf(source, asGiven);
|
|
46
|
+
const verbs = accessOf(source, withRequestState, access);
|
|
47
|
+
return {
|
|
48
|
+
avClient: operationTree("avClient", source, (framework) => framework.serverClient, withRequestState, {
|
|
49
|
+
all: () => all,
|
|
50
|
+
tx: () => builders(source, "avClient.tx").serverClientTx,
|
|
51
|
+
transaction: () => clientTransaction,
|
|
52
|
+
$access: () => verbs,
|
|
53
|
+
}),
|
|
54
|
+
avApp: operationTree("avApp", source, (framework) => framework.application, asGiven, {
|
|
55
|
+
appTx: () => builders(source, "avApp.appTx").appTx,
|
|
56
|
+
transaction: () => applicationTransaction,
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const ACCESS_VERB_OPTIONS = [
|
|
61
|
+
["can", 1],
|
|
62
|
+
...ACCESS_VERB_NAMES.map((name) => [name, ACCESS_VERBS[name].args ? 1 : 0]),
|
|
63
|
+
];
|
|
64
|
+
function servedVerbs(access) {
|
|
65
|
+
if (access === undefined) {
|
|
66
|
+
return new Set();
|
|
67
|
+
}
|
|
68
|
+
const verbs = access.client?.verbs ?? {};
|
|
69
|
+
return new Set([
|
|
70
|
+
"can",
|
|
71
|
+
...ACCESS_VERB_NAMES.filter((name) => providerVerb(verbs, name) !== undefined),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
function unservedVerb(name, access) {
|
|
75
|
+
const why = access === undefined
|
|
76
|
+
? `avClient.$access.${name}: this Framework has no \`access\`, so avClient has no $access.`
|
|
77
|
+
: `avClient.$access.${name}: the access provider offers no \`${name}\` (its \`client\` states no such verb).`;
|
|
78
|
+
return async () => {
|
|
79
|
+
throw new AvProtocolError("A2002", why);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function accessOf(source, fill, access) {
|
|
83
|
+
const served = servedVerbs(access);
|
|
84
|
+
const verbs = {};
|
|
85
|
+
const unserved = new Map();
|
|
86
|
+
for (const [name, at] of ACCESS_VERB_OPTIONS) {
|
|
87
|
+
if (!served.has(name)) {
|
|
88
|
+
unserved.set(name, unservedVerb(name, access));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
verbs[name] = async (...args) => {
|
|
92
|
+
await ioBeforeCall();
|
|
93
|
+
const framework = await source.built();
|
|
94
|
+
const call = framework.serverClient.$access?.[name];
|
|
95
|
+
if (call === undefined) {
|
|
96
|
+
return unservedVerb(name, access)();
|
|
97
|
+
}
|
|
98
|
+
const given = args[at];
|
|
99
|
+
const called = args.slice(0, at);
|
|
100
|
+
called[at] =
|
|
101
|
+
name === "can"
|
|
102
|
+
? await fill(given)
|
|
103
|
+
: given?.headers !== undefined
|
|
104
|
+
? given
|
|
105
|
+
: { ...given, headers: await headers() };
|
|
106
|
+
return call(...called);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return new Proxy(Object.freeze(verbs), {
|
|
110
|
+
get: (target, key, receiver) => typeof key === "string" && unserved.has(key)
|
|
111
|
+
? unserved.get(key)
|
|
112
|
+
: Reflect.get(target, key, receiver),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function builders(source, member) {
|
|
116
|
+
const framework = source.current();
|
|
117
|
+
if (framework === undefined) {
|
|
118
|
+
throw new Error(handlesBeforeBuild(member));
|
|
119
|
+
}
|
|
120
|
+
return framework;
|
|
121
|
+
}
|
|
122
|
+
function transactionOf(source, fill) {
|
|
123
|
+
return async (operations, options) => {
|
|
124
|
+
await ioBeforeCall();
|
|
125
|
+
const framework = await source.built();
|
|
126
|
+
return framework.transaction(operations, await fill(options));
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const NEVER_A_RESOURCE = new Set(["then", "__esModule"]);
|
|
130
|
+
function allOf(source) {
|
|
131
|
+
return async (entries, options) => {
|
|
132
|
+
await ioBeforeCall();
|
|
133
|
+
const framework = await source.built();
|
|
134
|
+
return framework.serverClient.all(entries, options);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function operationTree(name, source, layer, fill, members) {
|
|
138
|
+
const leaf = (path) => async (args, options) => {
|
|
139
|
+
await ioBeforeCall();
|
|
140
|
+
const framework = await source.built();
|
|
141
|
+
const call = memberAt(layer(framework), path);
|
|
142
|
+
if (typeof call !== "function") {
|
|
143
|
+
throw new TypeError(`${name}.${path.join(".")} is not a function`);
|
|
144
|
+
}
|
|
145
|
+
return call(args, await fill(options));
|
|
146
|
+
};
|
|
147
|
+
const resources = new Map();
|
|
148
|
+
return new Proxy(Object.freeze(Object.create(null)), {
|
|
149
|
+
get: (_target, key) => {
|
|
150
|
+
if (typeof key !== "string" || NEVER_A_RESOURCE.has(key)) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const member = Object.hasOwn(members, key) ? members[key] : undefined;
|
|
154
|
+
if (member !== undefined) {
|
|
155
|
+
return member();
|
|
156
|
+
}
|
|
157
|
+
let resource = resources.get(key);
|
|
158
|
+
if (resource === undefined) {
|
|
159
|
+
resource = keyedNodes((family) => keyedNodes((variant) => leaf([key, family, variant])));
|
|
160
|
+
resources.set(key, resource);
|
|
161
|
+
}
|
|
162
|
+
return resource;
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function keyedNodes(child) {
|
|
167
|
+
const children = new Map();
|
|
168
|
+
return new Proxy(Object.freeze(Object.create(null)), {
|
|
169
|
+
get: (_target, key) => {
|
|
170
|
+
if (typeof key !== "string" || NEVER_A_RESOURCE.has(key)) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
if (!children.has(key)) {
|
|
174
|
+
children.set(key, child(key));
|
|
175
|
+
}
|
|
176
|
+
return children.get(key);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function memberAt(root, path) {
|
|
181
|
+
let node = root;
|
|
182
|
+
for (const key of path) {
|
|
183
|
+
if (typeof node !== "object" ||
|
|
184
|
+
node === null ||
|
|
185
|
+
!Object.hasOwn(node, key)) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
node = node[key];
|
|
189
|
+
}
|
|
190
|
+
return node;
|
|
191
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AvFramework } from "@aventara/core";
|
|
2
|
+
/** One `api` path's build, shared by every copy of the module that calls `Aventara()` with the same options. */
|
|
3
|
+
export type FrameworkSlot = {
|
|
4
|
+
readonly fingerprint: string;
|
|
5
|
+
/** The build: once started, kept, a construction error included. */
|
|
6
|
+
framework: Promise<AvFramework> | undefined;
|
|
7
|
+
/** The Framework once built. */
|
|
8
|
+
built: AvFramework | undefined;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The slot for `api` in this process: the one a copy of the same module
|
|
12
|
+
* already holds (the same `fingerprint`), else a new one. A different
|
|
13
|
+
* fingerprint is refused in production and replaces the slot otherwise.
|
|
14
|
+
*
|
|
15
|
+
* @throws Error when another configuration holds `api` in production.
|
|
16
|
+
*/
|
|
17
|
+
export declare function frameworkSlot(api: string, fingerprint: string, source: string): FrameworkSlot;
|
|
18
|
+
/**
|
|
19
|
+
* The options' fingerprint: their plain data in a stable order; a function or
|
|
20
|
+
* any other object than plain data and arrays counts only as present, since
|
|
21
|
+
* bundlers spell them differently in each layer of one app. With `functions`
|
|
22
|
+
* `"source"`, a function counts by its source text instead: what one layer
|
|
23
|
+
* compares with its own last call.
|
|
24
|
+
*/
|
|
25
|
+
export declare function optionsFingerprint(options: unknown, functions?: "presence" | "source"): string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const KEY_PREFIX = "aventara.next.framework:";
|
|
2
|
+
const environment = () => globalThis.process?.env;
|
|
3
|
+
const lastCalls = new Map();
|
|
4
|
+
const frameworkKey = (api) => Symbol.for(`${KEY_PREFIX}${api}`);
|
|
5
|
+
function claimed(api) {
|
|
6
|
+
return `Aventara: another configuration already serves the \`api\` path ${api === "" ? "/" : api} in this process; give each Aventara() its own \`api\` path.`;
|
|
7
|
+
}
|
|
8
|
+
export function frameworkSlot(api, fingerprint, source) {
|
|
9
|
+
const key = frameworkKey(api);
|
|
10
|
+
const global = globalThis;
|
|
11
|
+
const held = global[key];
|
|
12
|
+
const production = environment()?.NODE_ENV === "production";
|
|
13
|
+
const last = lastCalls.get(api);
|
|
14
|
+
if (held !== undefined) {
|
|
15
|
+
if (held.fingerprint === fingerprint) {
|
|
16
|
+
const edited = !production && last?.slot === held && last.source !== source;
|
|
17
|
+
if (!edited) {
|
|
18
|
+
lastCalls.set(api, { source, slot: held });
|
|
19
|
+
return held;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else if (production) {
|
|
23
|
+
throw new Error(claimed(api));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const slot = {
|
|
27
|
+
fingerprint,
|
|
28
|
+
framework: undefined,
|
|
29
|
+
built: undefined,
|
|
30
|
+
};
|
|
31
|
+
global[key] = slot;
|
|
32
|
+
lastCalls.set(api, { source, slot });
|
|
33
|
+
return slot;
|
|
34
|
+
}
|
|
35
|
+
export function optionsFingerprint(options, functions = "presence") {
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const walk = (value) => {
|
|
38
|
+
switch (typeof value) {
|
|
39
|
+
case "function":
|
|
40
|
+
return functions === "source"
|
|
41
|
+
? `fn${JSON.stringify(Function.prototype.toString.call(value))}`
|
|
42
|
+
: "fn";
|
|
43
|
+
case "bigint":
|
|
44
|
+
return `${value}n`;
|
|
45
|
+
case "object":
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
return String(JSON.stringify(value));
|
|
49
|
+
}
|
|
50
|
+
if (value === null) {
|
|
51
|
+
return "null";
|
|
52
|
+
}
|
|
53
|
+
if (seen.has(value)) {
|
|
54
|
+
return "[cycle]";
|
|
55
|
+
}
|
|
56
|
+
seen.add(value);
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
let text = "[";
|
|
59
|
+
for (const member of value) {
|
|
60
|
+
text += `${walk(member)},`;
|
|
61
|
+
}
|
|
62
|
+
return `${text}]`;
|
|
63
|
+
}
|
|
64
|
+
const prototype = Object.getPrototypeOf(value);
|
|
65
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
66
|
+
return "obj";
|
|
67
|
+
}
|
|
68
|
+
let text = "{";
|
|
69
|
+
for (const key of Object.keys(value).sort()) {
|
|
70
|
+
text += `${JSON.stringify(key)}:${walk(value[key])},`;
|
|
71
|
+
}
|
|
72
|
+
return `${text}}`;
|
|
73
|
+
};
|
|
74
|
+
return walk(options);
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { StateSeed } from "./protocol-route.table.js";
|
|
2
|
+
import { type ProtocolHost } from "./route-handlers.factory.js";
|
|
3
|
+
/** The `globalThis` key the in-process path is registered under. */
|
|
4
|
+
export declare const UNIVERSAL_PATH_KEY: unique symbol;
|
|
5
|
+
/** The request the generated client's transport hands to its `fetch`: a cached read's GET has no body. */
|
|
6
|
+
export type UniversalRequest = {
|
|
7
|
+
readonly method: string;
|
|
8
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
9
|
+
readonly body?: string;
|
|
10
|
+
};
|
|
11
|
+
/** What the generated client's transport reads of a `fetch` answer. */
|
|
12
|
+
export type UniversalAnswer = {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
text(): Promise<string>;
|
|
15
|
+
};
|
|
16
|
+
/** The in-process path: the generated client's request in, the protocol's answer out. */
|
|
17
|
+
export type UniversalPath = (url: string, init: UniversalRequest) => Promise<UniversalAnswer>;
|
|
18
|
+
/**
|
|
19
|
+
* The in-process path over the protocol `host` resolves to: each request is
|
|
20
|
+
* answered by the bound member its route names, with the state `state` seeds,
|
|
21
|
+
* or by the protocol's absence answer.
|
|
22
|
+
*/
|
|
23
|
+
export declare function universalPath(host: () => Promise<ProtocolHost>, state: StateSeed | undefined): UniversalPath;
|
|
24
|
+
/**
|
|
25
|
+
* Registers `path` as this server's in-process path for `@/aventara/universal`,
|
|
26
|
+
* and `access`, `avClient.$access`, as its `$access` verbs on the server.
|
|
27
|
+
*/
|
|
28
|
+
export declare function registerUniversalPath(path: UniversalPath, access?: object): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { routeKey } from "./route-handlers.factory.js";
|
|
2
|
+
export const UNIVERSAL_PATH_KEY = Symbol.for("aventara.next.universal");
|
|
3
|
+
const IN_PROCESS = new AbortController().signal;
|
|
4
|
+
function outsideApi(url, api) {
|
|
5
|
+
return `Aventara: @/aventara/universal called ${url} on the server, which is not under the configured \`api\` path ${api === "" ? "/" : api}; run aventara-build --client again (npm run aventara:prepare).`;
|
|
6
|
+
}
|
|
7
|
+
export function universalPath(host, state) {
|
|
8
|
+
return async (url, init) => {
|
|
9
|
+
const { binding, api, routes } = await host();
|
|
10
|
+
if (url !== api && !url.startsWith(`${api}/`)) {
|
|
11
|
+
throw new Error(outsideApi(url, api));
|
|
12
|
+
}
|
|
13
|
+
const [path = "", query] = splitQuery(url.slice(api.length));
|
|
14
|
+
const request = {
|
|
15
|
+
method: init.method,
|
|
16
|
+
path,
|
|
17
|
+
...(query === undefined ? {} : { query }),
|
|
18
|
+
headers: Object.fromEntries(new Headers(init.headers)),
|
|
19
|
+
body: { kind: "raw", content: init.body ?? "" },
|
|
20
|
+
};
|
|
21
|
+
const answer = routes.get(routeKey(init.method, path));
|
|
22
|
+
const response = answer === undefined
|
|
23
|
+
? binding.answerAbsent(request)
|
|
24
|
+
: await answer(request, state, IN_PROCESS);
|
|
25
|
+
if (response === undefined) {
|
|
26
|
+
throw new Error(outsideApi(url, api));
|
|
27
|
+
}
|
|
28
|
+
const { body } = response;
|
|
29
|
+
return {
|
|
30
|
+
status: response.status,
|
|
31
|
+
text: async () => (typeof body === "string" ? body : await joined(body)),
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function splitQuery(url) {
|
|
36
|
+
const mark = url.indexOf("?");
|
|
37
|
+
return mark === -1
|
|
38
|
+
? [url, undefined]
|
|
39
|
+
: [url.slice(0, mark), url.slice(mark + 1)];
|
|
40
|
+
}
|
|
41
|
+
async function joined(lines) {
|
|
42
|
+
let text = "";
|
|
43
|
+
for await (const line of lines) {
|
|
44
|
+
text += line;
|
|
45
|
+
}
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
const UNIVERSAL_ACCESS_KEY = Symbol.for("aventara.next.universal.access");
|
|
49
|
+
export function registerUniversalPath(path, access) {
|
|
50
|
+
const holder = globalThis;
|
|
51
|
+
holder[UNIVERSAL_PATH_KEY] = path;
|
|
52
|
+
if (access !== undefined) {
|
|
53
|
+
holder[UNIVERSAL_ACCESS_KEY] = access;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes data an Aventara client answered safe to pass from the server to the
|
|
3
|
+
* browser: each `Date`, `bigint`, `Uint8Array` and decimal becomes a tagged
|
|
4
|
+
* plain object, which `avHydrate` (from `@/aventara/client`) revives in the
|
|
5
|
+
* browser, a decimal as the browser client's own `AvDecimal`. Arrays and
|
|
6
|
+
* plain objects are copied with their members dehydrated; any other value is
|
|
7
|
+
* returned as it is.
|
|
8
|
+
*
|
|
9
|
+
* Give it to TanStack Query as `dehydrate(queryClient, { serializeData: avDehydrate })`,
|
|
10
|
+
* or call it on an SWR `fallback` in a Server Component.
|
|
11
|
+
*
|
|
12
|
+
* @throws TypeError for a `Date` with no wire form (invalid, or a year past four digits).
|
|
13
|
+
*/
|
|
14
|
+
export declare function avDehydrate(data: unknown): unknown;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { AvProtocol } from "@aventara/core/protocol";
|
|
2
|
+
import { HYDRATION_FORMAT } from "./hydration-tag.format.js";
|
|
3
|
+
const { tagKey, valueKey, objectTag } = HYDRATION_FORMAT;
|
|
4
|
+
const DECIMAL = new RegExp(AvProtocol.scalarFormats.decimal);
|
|
5
|
+
const DATETIME = new RegExp(AvProtocol.scalarFormats.datetime);
|
|
6
|
+
const tagged = (tag, wire) => ({
|
|
7
|
+
[tagKey]: tag,
|
|
8
|
+
[valueKey]: wire,
|
|
9
|
+
});
|
|
10
|
+
function isPlainObject(value) {
|
|
11
|
+
const prototype = Object.getPrototypeOf(value);
|
|
12
|
+
return prototype === Object.prototype || prototype === null;
|
|
13
|
+
}
|
|
14
|
+
function decimalString(value) {
|
|
15
|
+
const methods = value;
|
|
16
|
+
if (typeof methods.toString !== "function" ||
|
|
17
|
+
typeof methods.toJSON !== "function") {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const digits = methods.toString.call(value);
|
|
21
|
+
return typeof digits === "string" &&
|
|
22
|
+
DECIMAL.test(digits) &&
|
|
23
|
+
methods.toJSON.call(value) === digits
|
|
24
|
+
? digits
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
function base64Of(bytes) {
|
|
28
|
+
let binary = "";
|
|
29
|
+
for (const byte of bytes) {
|
|
30
|
+
binary += String.fromCharCode(byte);
|
|
31
|
+
}
|
|
32
|
+
return btoa(binary);
|
|
33
|
+
}
|
|
34
|
+
function datetimeOf(date) {
|
|
35
|
+
const wire = Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
|
36
|
+
if (!DATETIME.test(wire)) {
|
|
37
|
+
throw new TypeError("avDehydrate: a Date that is invalid, or whose year is not four digits, has no wire form.");
|
|
38
|
+
}
|
|
39
|
+
return wire;
|
|
40
|
+
}
|
|
41
|
+
function dehydratedMembers(record) {
|
|
42
|
+
return Object.fromEntries(Object.entries(record).map(([key, member]) => [key, avDehydrate(member)]));
|
|
43
|
+
}
|
|
44
|
+
export function avDehydrate(data) {
|
|
45
|
+
switch (typeof data) {
|
|
46
|
+
case "bigint":
|
|
47
|
+
return tagged("bigint", data.toString(10));
|
|
48
|
+
case "object":
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
if (data === null) {
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(data)) {
|
|
57
|
+
return data.map((member) => avDehydrate(member));
|
|
58
|
+
}
|
|
59
|
+
if (data instanceof Date) {
|
|
60
|
+
return tagged("datetime", datetimeOf(data));
|
|
61
|
+
}
|
|
62
|
+
if (data instanceof Uint8Array) {
|
|
63
|
+
return tagged("bytes", base64Of(data));
|
|
64
|
+
}
|
|
65
|
+
if (isPlainObject(data)) {
|
|
66
|
+
return Object.hasOwn(data, tagKey)
|
|
67
|
+
? tagged(objectTag, dehydratedMembers(data))
|
|
68
|
+
: dehydratedMembers(data);
|
|
69
|
+
}
|
|
70
|
+
const decimal = decimalString(data);
|
|
71
|
+
return decimal === undefined ? data : tagged("decimal", decimal);
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AvProtocolRequest } from "@aventara/core/protocol";
|
|
2
|
+
import type { McpRequest } from "./mcp-binding.loader.js";
|
|
3
|
+
import type { RouteResponse } from "./protocol-route.table.js";
|
|
4
|
+
/** Zero bytes: the body of a request whose body the protocol does not read. */
|
|
5
|
+
export declare const NO_BYTES: Uint8Array<ArrayBuffer>;
|
|
6
|
+
/**
|
|
7
|
+
* A request URL's pathname, as the `Request` carries it: the query dropped and
|
|
8
|
+
* nothing URL-decoded.
|
|
9
|
+
*/
|
|
10
|
+
export declare function pathnameOf(request: Request): string;
|
|
11
|
+
/**
|
|
12
|
+
* The request as the protocol reads it: path relative to the mount path, raw
|
|
13
|
+
* query (rc.11: a cached read's `q` and `v`), its headers, raw body.
|
|
14
|
+
*/
|
|
15
|
+
export declare function protocolRequest(request: Request, path: string, content: Uint8Array): AvProtocolRequest;
|
|
16
|
+
/** The request as `@aventara/mcp` reads it: absolute URL at the origin the caller used, its headers, raw body, no peer address. */
|
|
17
|
+
export declare function mcpRequest(request: Request, content: Uint8Array): McpRequest;
|
|
18
|
+
/**
|
|
19
|
+
* The protocol's answer as the route handler's `Response`: status, headers and
|
|
20
|
+
* body as they are. A streamed body (`/_all` under NDJSON) is a
|
|
21
|
+
* `ReadableStream` that pulls each line as core yields it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function webResponse(answer: RouteResponse): Response;
|