@boxd-sh/convex 0.1.0
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/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +327 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +126 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +52 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +357 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +135 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +80 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/boxd.d.ts +35 -0
- package/dist/component/boxd.d.ts.map +1 -0
- package/dist/component/boxd.js +144 -0
- package/dist/component/boxd.js.map +1 -0
- package/dist/component/convex.config.d.ts +6 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +9 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/errors.d.ts +25 -0
- package/dist/component/errors.d.ts.map +1 -0
- package/dist/component/errors.js +75 -0
- package/dist/component/errors.js.map +1 -0
- package/dist/component/exec.d.ts +25 -0
- package/dist/component/exec.d.ts.map +1 -0
- package/dist/component/exec.js +100 -0
- package/dist/component/exec.js.map +1 -0
- package/dist/component/executions.d.ts +70 -0
- package/dist/component/executions.d.ts.map +1 -0
- package/dist/component/executions.js +106 -0
- package/dist/component/executions.js.map +1 -0
- package/dist/component/files.d.ts +44 -0
- package/dist/component/files.d.ts.map +1 -0
- package/dist/component/files.js +105 -0
- package/dist/component/files.js.map +1 -0
- package/dist/component/limits.d.ts +33 -0
- package/dist/component/limits.d.ts.map +1 -0
- package/dist/component/limits.js +44 -0
- package/dist/component/limits.js.map +1 -0
- package/dist/component/machines.d.ts +337 -0
- package/dist/component/machines.d.ts.map +1 -0
- package/dist/component/machines.js +411 -0
- package/dist/component/machines.js.map +1 -0
- package/dist/component/records.d.ts +87 -0
- package/dist/component/records.d.ts.map +1 -0
- package/dist/component/records.js +51 -0
- package/dist/component/records.js.map +1 -0
- package/dist/component/schema.d.ts +138 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +70 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/sessions.d.ts +21 -0
- package/dist/component/sessions.d.ts.map +1 -0
- package/dist/component/sessions.js +59 -0
- package/dist/component/sessions.js.map +1 -0
- package/dist/component/validate.d.ts +15 -0
- package/dist/component/validate.d.ts.map +1 -0
- package/dist/component/validate.js +44 -0
- package/dist/component/validate.js.map +1 -0
- package/package.json +99 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.ts +199 -0
- package/src/component/_generated/api.ts +68 -0
- package/src/component/_generated/component.ts +430 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +171 -0
- package/src/component/boxd.ts +188 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/errors.ts +105 -0
- package/src/component/exec.ts +134 -0
- package/src/component/executions.ts +115 -0
- package/src/component/files.ts +128 -0
- package/src/component/limits.ts +48 -0
- package/src/component/machines.ts +500 -0
- package/src/component/records.ts +71 -0
- package/src/component/schema.ts +77 -0
- package/src/component/sessions.ts +61 -0
- package/src/component/validate.ts +58 -0
- package/src/test.ts +28 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* Generated data model types.
|
|
4
|
+
*
|
|
5
|
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
|
6
|
+
*
|
|
7
|
+
* To regenerate, run `npx convex dev`.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
DataModelFromSchemaDefinition,
|
|
13
|
+
DocumentByName,
|
|
14
|
+
TableNamesInDataModel,
|
|
15
|
+
SystemTableNames,
|
|
16
|
+
} from "convex/server";
|
|
17
|
+
import type { GenericId } from "convex/values";
|
|
18
|
+
import schema from "../schema.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The names of all of your Convex tables.
|
|
22
|
+
*/
|
|
23
|
+
export type TableNames = TableNamesInDataModel<DataModel>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The type of a document stored in Convex.
|
|
27
|
+
*
|
|
28
|
+
* @typeParam TableName - A string literal type of the table name (like "users").
|
|
29
|
+
*/
|
|
30
|
+
export type Doc<TableName extends TableNames> = DocumentByName<
|
|
31
|
+
DataModel,
|
|
32
|
+
TableName
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An identifier for a document in Convex.
|
|
37
|
+
*
|
|
38
|
+
* Convex documents are uniquely identified by their `Id`, which is accessible
|
|
39
|
+
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
|
40
|
+
*
|
|
41
|
+
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
|
42
|
+
*
|
|
43
|
+
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
|
44
|
+
* strings when type checking.
|
|
45
|
+
*
|
|
46
|
+
* @typeParam TableName - A string literal type of the table name (like "users").
|
|
47
|
+
*/
|
|
48
|
+
export type Id<TableName extends TableNames | SystemTableNames> =
|
|
49
|
+
GenericId<TableName>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A type describing your Convex data model.
|
|
53
|
+
*
|
|
54
|
+
* This type includes information about what tables you have, the type of
|
|
55
|
+
* documents stored in those tables, and the indexes defined on them.
|
|
56
|
+
*
|
|
57
|
+
* This type is used to parameterize methods like `queryGeneric` and
|
|
58
|
+
* `mutationGeneric` to make them type-safe.
|
|
59
|
+
*/
|
|
60
|
+
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* Generated utilities for implementing server-side Convex query and mutation functions.
|
|
4
|
+
*
|
|
5
|
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
|
6
|
+
*
|
|
7
|
+
* To regenerate, run `npx convex dev`.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ActionBuilder,
|
|
13
|
+
HttpActionBuilder,
|
|
14
|
+
MutationBuilder,
|
|
15
|
+
QueryBuilder,
|
|
16
|
+
GenericActionCtx,
|
|
17
|
+
GenericMutationCtx,
|
|
18
|
+
GenericQueryCtx,
|
|
19
|
+
GenericDatabaseReader,
|
|
20
|
+
GenericDatabaseWriter,
|
|
21
|
+
} from "convex/server";
|
|
22
|
+
import {
|
|
23
|
+
actionGeneric,
|
|
24
|
+
httpActionGeneric,
|
|
25
|
+
queryGeneric,
|
|
26
|
+
mutationGeneric,
|
|
27
|
+
internalActionGeneric,
|
|
28
|
+
internalMutationGeneric,
|
|
29
|
+
internalQueryGeneric,
|
|
30
|
+
} from "convex/server";
|
|
31
|
+
import type { DataModel } from "./dataModel.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Typesafe environment variables.
|
|
35
|
+
*
|
|
36
|
+
* This includes platform-provided env vars and any variables declared in
|
|
37
|
+
* `convex.config.ts`.
|
|
38
|
+
*/
|
|
39
|
+
type Env = {
|
|
40
|
+
readonly CONVEX_CLOUD_URL: string;
|
|
41
|
+
readonly CONVEX_SITE_URL: string;
|
|
42
|
+
readonly BOXD_API_KEY: string;
|
|
43
|
+
readonly BOXD_BASE_URL: string | undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Define a query in this Convex app's public API.
|
|
48
|
+
*
|
|
49
|
+
* This function will be allowed to read your Convex database and will be accessible from the client.
|
|
50
|
+
*
|
|
51
|
+
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
|
52
|
+
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
|
53
|
+
*/
|
|
54
|
+
export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Define a query that is only accessible from other Convex functions (but not from the client).
|
|
58
|
+
*
|
|
59
|
+
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
|
60
|
+
*
|
|
61
|
+
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
|
62
|
+
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
|
63
|
+
*/
|
|
64
|
+
export const internalQuery: QueryBuilder<DataModel, "internal"> =
|
|
65
|
+
internalQueryGeneric;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Define a mutation in this Convex app's public API.
|
|
69
|
+
*
|
|
70
|
+
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
|
71
|
+
*
|
|
72
|
+
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
|
73
|
+
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
|
74
|
+
*/
|
|
75
|
+
export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
|
79
|
+
*
|
|
80
|
+
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
|
81
|
+
*
|
|
82
|
+
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
|
83
|
+
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
|
84
|
+
*/
|
|
85
|
+
export const internalMutation: MutationBuilder<DataModel, "internal"> =
|
|
86
|
+
internalMutationGeneric;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Define an action in this Convex app's public API.
|
|
90
|
+
*
|
|
91
|
+
* An action is a function which can execute any JavaScript code, including non-deterministic
|
|
92
|
+
* code and code with side-effects, like calling third-party services.
|
|
93
|
+
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
|
94
|
+
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
|
95
|
+
*
|
|
96
|
+
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
|
97
|
+
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
|
98
|
+
*/
|
|
99
|
+
export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Define an action that is only accessible from other Convex functions (but not from the client).
|
|
103
|
+
*
|
|
104
|
+
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
|
105
|
+
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
|
106
|
+
*/
|
|
107
|
+
export const internalAction: ActionBuilder<DataModel, "internal"> =
|
|
108
|
+
internalActionGeneric;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Define an HTTP action.
|
|
112
|
+
*
|
|
113
|
+
* The wrapped function will be used to respond to HTTP requests received
|
|
114
|
+
* by a Convex deployment if the requests matches the path and method where
|
|
115
|
+
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
|
116
|
+
*
|
|
117
|
+
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
|
118
|
+
* and a Fetch API `Request` object as its second.
|
|
119
|
+
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
|
120
|
+
*/
|
|
121
|
+
export const httpAction: HttpActionBuilder = httpActionGeneric;
|
|
122
|
+
export const env: Env = (globalThis as unknown as { process: { env: Env } })
|
|
123
|
+
.process.env;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A set of services for use within Convex query functions.
|
|
127
|
+
*
|
|
128
|
+
* The query context is passed as the first argument to any Convex query
|
|
129
|
+
* function run on the server.
|
|
130
|
+
*
|
|
131
|
+
* If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
|
|
132
|
+
*/
|
|
133
|
+
export type QueryCtx = GenericQueryCtx<DataModel>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A set of services for use within Convex mutation functions.
|
|
137
|
+
*
|
|
138
|
+
* The mutation context is passed as the first argument to any Convex mutation
|
|
139
|
+
* function run on the server.
|
|
140
|
+
*
|
|
141
|
+
* If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
|
|
142
|
+
*/
|
|
143
|
+
export type MutationCtx = GenericMutationCtx<DataModel>;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A set of services for use within Convex action functions.
|
|
147
|
+
*
|
|
148
|
+
* The action context is passed as the first argument to any Convex action
|
|
149
|
+
* function run on the server.
|
|
150
|
+
*/
|
|
151
|
+
export type ActionCtx = GenericActionCtx<DataModel>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* An interface to read from the database within Convex query functions.
|
|
155
|
+
*
|
|
156
|
+
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
|
157
|
+
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
|
158
|
+
* building a query.
|
|
159
|
+
*/
|
|
160
|
+
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* An interface to read from and write to the database within Convex mutation
|
|
164
|
+
* functions.
|
|
165
|
+
*
|
|
166
|
+
* Convex guarantees that all writes within a single mutation are
|
|
167
|
+
* executed atomically, so you never have to worry about partial writes leaving
|
|
168
|
+
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
|
169
|
+
* for the guarantees Convex provides your functions.
|
|
170
|
+
*/
|
|
171
|
+
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connects component actions to boxd.
|
|
3
|
+
*
|
|
4
|
+
* The component runs in Convex's default JavaScript runtime, so it uses the
|
|
5
|
+
* SDK's `@boxd-sh/sdk/web` entry: grpc-web over `fetch`, no Node built-ins.
|
|
6
|
+
*
|
|
7
|
+
* The API key comes from the component's own env (`BOXD_API_KEY`, bound by
|
|
8
|
+
* the app in `app.use`), so it never travels as a function argument. The SDK
|
|
9
|
+
* could exchange the key for a session token itself, but it would do so once
|
|
10
|
+
* per action: an SDK client lives only as long as the action. boxd
|
|
11
|
+
* rate-limits the exchange per source IP, and Convex deployments share their
|
|
12
|
+
* egress IPs. So the component exchanges the key once, caches the token in
|
|
13
|
+
* its `sessions` table, and hands the SDK the token.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Boxd } from "@boxd-sh/sdk/web";
|
|
17
|
+
import { internal } from "./_generated/api.js";
|
|
18
|
+
import { env, type ActionCtx } from "./_generated/server.js";
|
|
19
|
+
import { boxdError, toConvexError } from "./errors.js";
|
|
20
|
+
|
|
21
|
+
/** boxd production. `BOXD_BASE_URL` points the component anywhere else. */
|
|
22
|
+
export const DEFAULT_BASE_URL = "https://boxd.sh:9443";
|
|
23
|
+
|
|
24
|
+
const EXCHANGE_PATH = "/api/v1/auth/token";
|
|
25
|
+
|
|
26
|
+
/** Exchange again this long before the cached token expires. */
|
|
27
|
+
const REFRESH_SKEW_MS = 5 * 60_000;
|
|
28
|
+
|
|
29
|
+
type Session = { token: string; expiresAt: number };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The console origin that serves the key exchange: `https://app.<zone>` for a
|
|
33
|
+
* cluster zone, or the endpoint's own origin for a local, IP or `app.` host.
|
|
34
|
+
* Mirrors `consoleBaseUrl` in `@boxd-sh/sdk`, which the SDK doesn't export.
|
|
35
|
+
*/
|
|
36
|
+
export function exchangeUrl(baseURL: string): string {
|
|
37
|
+
const trimmed = baseURL.trim().replace(/\/+$/, "");
|
|
38
|
+
const scheme = /^https?:\/\//.exec(trimmed)?.[0];
|
|
39
|
+
const local = /^(localhost|127\.)/.test(trimmed.slice(scheme?.length ?? 0));
|
|
40
|
+
// No scheme: pick the one the SDK would dial, plain HTTP for a local host.
|
|
41
|
+
const url = new URL(
|
|
42
|
+
scheme ? trimmed : `${local ? "http" : "https"}://${trimmed}`,
|
|
43
|
+
);
|
|
44
|
+
const host = url.hostname;
|
|
45
|
+
const sameOrigin =
|
|
46
|
+
host === "localhost" ||
|
|
47
|
+
host.startsWith("[") ||
|
|
48
|
+
/^\d{1,3}(\.\d{1,3}){3}$/.test(host) ||
|
|
49
|
+
host.startsWith("app.");
|
|
50
|
+
const origin = sameOrigin ? url.origin : `https://app.${host}`;
|
|
51
|
+
return `${origin}${EXCHANGE_PATH}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function sha256Hex(text: string): Promise<string> {
|
|
55
|
+
const digest = await crypto.subtle.digest(
|
|
56
|
+
"SHA-256",
|
|
57
|
+
new TextEncoder().encode(text),
|
|
58
|
+
);
|
|
59
|
+
return Array.from(new Uint8Array(digest), (b) =>
|
|
60
|
+
b.toString(16).padStart(2, "0"),
|
|
61
|
+
).join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function exchange(apiKey: string, baseURL: string): Promise<Session> {
|
|
65
|
+
const url = exchangeUrl(baseURL);
|
|
66
|
+
let response: Response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetch(url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ api_key: apiKey }),
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw boxdError(
|
|
75
|
+
"UNAVAILABLE",
|
|
76
|
+
`could not reach ${url} to exchange BOXD_API_KEY: ${String(error)}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (response.status === 401) {
|
|
80
|
+
throw boxdError(
|
|
81
|
+
"UNAUTHENTICATED",
|
|
82
|
+
"boxd rejected BOXD_API_KEY: it is invalid, expired or revoked. " +
|
|
83
|
+
"Create a new key with `boxd auth keys create <name>` and set it with " +
|
|
84
|
+
"`npx convex env set BOXD_API_KEY <key>`.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (response.status === 429) {
|
|
88
|
+
throw boxdError(
|
|
89
|
+
"RATE_LIMITED",
|
|
90
|
+
"boxd rate-limited the API key exchange. Retry after a minute.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const detail = await response.text().catch(() => "");
|
|
95
|
+
throw boxdError(
|
|
96
|
+
response.status >= 500 ? "UNAVAILABLE" : "BOXD_ERROR",
|
|
97
|
+
`boxd API key exchange failed with ${response.status}: ${detail.slice(0, 500)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const body = (await response.json()) as {
|
|
101
|
+
token?: unknown;
|
|
102
|
+
expires_at?: unknown;
|
|
103
|
+
};
|
|
104
|
+
if (typeof body.token !== "string" || typeof body.expires_at !== "number") {
|
|
105
|
+
throw boxdError(
|
|
106
|
+
"BOXD_ERROR",
|
|
107
|
+
"boxd API key exchange returned a malformed response",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { token: body.token, expiresAt: body.expires_at * 1000 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function config(): { apiKey: string; baseURL: string } {
|
|
114
|
+
// Read inside the handler: component env only exists at runtime.
|
|
115
|
+
const apiKey = env.BOXD_API_KEY?.trim();
|
|
116
|
+
if (!apiKey) {
|
|
117
|
+
throw boxdError(
|
|
118
|
+
"UNAUTHENTICATED",
|
|
119
|
+
"BOXD_API_KEY is empty. Set it with `npx convex env set BOXD_API_KEY <key>` " +
|
|
120
|
+
"and bind it in convex.config.ts: " +
|
|
121
|
+
"`app.use(boxd, { env: { BOXD_API_KEY: app.env.BOXD_API_KEY } })`.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const baseURL = env.BOXD_BASE_URL?.trim() || DEFAULT_BASE_URL;
|
|
125
|
+
return { apiKey, baseURL };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function session(
|
|
129
|
+
ctx: ActionCtx,
|
|
130
|
+
keyHash: string,
|
|
131
|
+
apiKey: string,
|
|
132
|
+
baseURL: string,
|
|
133
|
+
): Promise<{ session: Session; cached: boolean }> {
|
|
134
|
+
const cached = await ctx.runQuery(internal.sessions.get, { keyHash });
|
|
135
|
+
if (cached && cached.expiresAt - REFRESH_SKEW_MS > Date.now()) {
|
|
136
|
+
return { session: cached, cached: true };
|
|
137
|
+
}
|
|
138
|
+
let fresh: Session;
|
|
139
|
+
try {
|
|
140
|
+
fresh = await exchange(apiKey, baseURL);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Many actions can reach the refresh margin at once, and boxd
|
|
143
|
+
// rate-limits the exchange. A token that has not expired yet still
|
|
144
|
+
// works, so fall back to it rather than fail the call.
|
|
145
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
146
|
+
return { session: cached, cached: true };
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
await ctx.runMutation(internal.sessions.put, { keyHash, ...fresh });
|
|
151
|
+
return { session: fresh, cached: false };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run `operation` with an authenticated SDK client, and turn whatever it
|
|
156
|
+
* throws into a coded `ConvexError`.
|
|
157
|
+
*
|
|
158
|
+
* A cached token can be revoked before it expires. If boxd rejects a cached
|
|
159
|
+
* token, the token is dropped, the key is exchanged again, and `operation`
|
|
160
|
+
* runs once more. That retry is safe for every operation, including create:
|
|
161
|
+
* boxd refuses an unauthenticated request before it does anything.
|
|
162
|
+
*/
|
|
163
|
+
export async function withBoxd<T>(
|
|
164
|
+
ctx: ActionCtx,
|
|
165
|
+
operation: (boxd: Boxd) => Promise<T>,
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const { apiKey, baseURL } = config();
|
|
168
|
+
const keyHash = await sha256Hex(`${baseURL}\n${apiKey}`);
|
|
169
|
+
const first = await session(ctx, keyHash, apiKey, baseURL);
|
|
170
|
+
try {
|
|
171
|
+
return await operation(new Boxd({ token: first.session.token, baseURL }));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const converted = toConvexError(error);
|
|
174
|
+
if (!first.cached || converted.data.code !== "UNAUTHENTICATED") {
|
|
175
|
+
throw converted;
|
|
176
|
+
}
|
|
177
|
+
await ctx.runMutation(internal.sessions.drop, {
|
|
178
|
+
keyHash,
|
|
179
|
+
token: first.session.token,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const retry = await session(ctx, keyHash, apiKey, baseURL);
|
|
183
|
+
try {
|
|
184
|
+
return await operation(new Boxd({ token: retry.session.token, baseURL }));
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw toConvexError(error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ConvexError, v, type Infer } from "convex/values";
|
|
2
|
+
import { MAX_STORED_ERROR, truncate } from "./limits.js";
|
|
3
|
+
|
|
4
|
+
export const errorCode = v.union(
|
|
5
|
+
v.literal("INVALID_ARGUMENT"),
|
|
6
|
+
v.literal("UNAUTHENTICATED"),
|
|
7
|
+
v.literal("PERMISSION_DENIED"),
|
|
8
|
+
v.literal("NOT_FOUND"),
|
|
9
|
+
v.literal("CONFLICT"),
|
|
10
|
+
v.literal("RATE_LIMITED"),
|
|
11
|
+
v.literal("TIMEOUT"),
|
|
12
|
+
v.literal("UNAVAILABLE"),
|
|
13
|
+
v.literal("BOXD_ERROR"),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/** The `data` of every `ConvexError` this component throws. */
|
|
17
|
+
export type BoxdErrorData = {
|
|
18
|
+
code: Infer<typeof errorCode>;
|
|
19
|
+
message: string;
|
|
20
|
+
/**
|
|
21
|
+
* Set when `create` or `fork` made a machine but it failed to become ready:
|
|
22
|
+
* the machine exists, and bills, until you destroy it.
|
|
23
|
+
*/
|
|
24
|
+
machineId?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// gRPC status codes, as carried on `BoxdError.grpcCode`.
|
|
28
|
+
const GRPC_CODES: Record<number, BoxdErrorData["code"]> = {
|
|
29
|
+
3: "INVALID_ARGUMENT",
|
|
30
|
+
4: "TIMEOUT",
|
|
31
|
+
5: "NOT_FOUND",
|
|
32
|
+
6: "CONFLICT",
|
|
33
|
+
7: "PERMISSION_DENIED",
|
|
34
|
+
8: "RATE_LIMITED",
|
|
35
|
+
9: "CONFLICT",
|
|
36
|
+
14: "UNAVAILABLE",
|
|
37
|
+
16: "UNAUTHENTICATED",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The SDK's own error classes, for errors raised before anything reached the
|
|
41
|
+
// wire (a missing credential, a failed key exchange, a dropped connection).
|
|
42
|
+
// Matched by name and `grpcCode` rather than `instanceof`, so the modules
|
|
43
|
+
// that only store and read rows never load the SDK.
|
|
44
|
+
const SDK_CLASSES: Record<string, BoxdErrorData["code"]> = {
|
|
45
|
+
AuthenticationError: "UNAUTHENTICATED",
|
|
46
|
+
PermissionDeniedError: "PERMISSION_DENIED",
|
|
47
|
+
NotFoundError: "NOT_FOUND",
|
|
48
|
+
ConflictError: "CONFLICT",
|
|
49
|
+
RateLimitError: "RATE_LIMITED",
|
|
50
|
+
APIConnectionError: "UNAVAILABLE",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The SDK's client-side deadlines: "exec timed out after 5000ms", and
|
|
54
|
+
// "machine x did not reach 'running' within 90000ms".
|
|
55
|
+
const TIMEOUT_MESSAGE = /timed out|within \d+ms/i;
|
|
56
|
+
|
|
57
|
+
export function boxdError(
|
|
58
|
+
code: BoxdErrorData["code"],
|
|
59
|
+
message: string,
|
|
60
|
+
): ConvexError<BoxdErrorData> {
|
|
61
|
+
return new ConvexError({ code, message });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function invalidArgument(message: string): ConvexError<BoxdErrorData> {
|
|
65
|
+
return boxdError("INVALID_ARGUMENT", message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A human-readable message for any thrown value, bounded for storage. */
|
|
69
|
+
export function errorMessage(error: unknown): string {
|
|
70
|
+
let message: string;
|
|
71
|
+
if (error instanceof ConvexError) {
|
|
72
|
+
const data = error.data as Partial<BoxdErrorData> | string;
|
|
73
|
+
message =
|
|
74
|
+
typeof data === "string" ? data : (data.message ?? String(error.data));
|
|
75
|
+
} else if (error instanceof Error) {
|
|
76
|
+
message = error.message;
|
|
77
|
+
} else {
|
|
78
|
+
message = String(error);
|
|
79
|
+
}
|
|
80
|
+
return truncate(message, MAX_STORED_ERROR);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Map anything thrown while talking to boxd onto a `ConvexError` with a
|
|
85
|
+
* stable `code`, so the app can branch on `error.data.code` instead of
|
|
86
|
+
* parsing messages. A `ConvexError` passes through unchanged.
|
|
87
|
+
*/
|
|
88
|
+
export function toConvexError(error: unknown): ConvexError<BoxdErrorData> {
|
|
89
|
+
if (error instanceof ConvexError) return error as ConvexError<BoxdErrorData>;
|
|
90
|
+
const message = errorMessage(error);
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
const { grpcCode } = error as { grpcCode?: unknown };
|
|
93
|
+
const code =
|
|
94
|
+
(typeof grpcCode === "number" ? GRPC_CODES[grpcCode] : undefined) ??
|
|
95
|
+
SDK_CLASSES[error.name] ??
|
|
96
|
+
(TIMEOUT_MESSAGE.test(message) ? "TIMEOUT" : "BOXD_ERROR");
|
|
97
|
+
return boxdError(code, message);
|
|
98
|
+
}
|
|
99
|
+
return boxdError("BOXD_ERROR", message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when `error` says the target no longer exists on boxd. */
|
|
103
|
+
export function isNotFound(error: unknown): boolean {
|
|
104
|
+
return toConvexError(error).data.code === "NOT_FOUND";
|
|
105
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a command in a machine. Each run is recorded in `executions`
|
|
3
|
+
* (running → completed or failed), so the app can render live status and
|
|
4
|
+
* history from a reactive query.
|
|
5
|
+
*
|
|
6
|
+
* The call returns when the command exits. For work that can outlast the 10
|
|
7
|
+
* minute action ceiling, start it in the background inside the machine
|
|
8
|
+
* (`nohup ... &`) and poll it with later runs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { v } from "convex/values";
|
|
12
|
+
import { internal } from "./_generated/api.js";
|
|
13
|
+
import type { Id } from "./_generated/dataModel.js";
|
|
14
|
+
import { action } from "./_generated/server.js";
|
|
15
|
+
import { withBoxd } from "./boxd.js";
|
|
16
|
+
import { errorMessage, isNotFound } from "./errors.js";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_EXEC_TIMEOUT_MS,
|
|
19
|
+
MAX_EXEC_TIMEOUT_MS,
|
|
20
|
+
MAX_RETURNED_OUTPUT,
|
|
21
|
+
MAX_STORED_COMMAND,
|
|
22
|
+
MAX_STORED_OUTPUT,
|
|
23
|
+
truncate,
|
|
24
|
+
} from "./limits.js";
|
|
25
|
+
import { requireMachine } from "./records.js";
|
|
26
|
+
import {
|
|
27
|
+
boundedTimeout,
|
|
28
|
+
requireAbsolutePath,
|
|
29
|
+
requireNonEmpty,
|
|
30
|
+
} from "./validate.js";
|
|
31
|
+
|
|
32
|
+
const SAFE_ARG = /^[A-Za-z0-9_\-./=:@%+,]+$/;
|
|
33
|
+
|
|
34
|
+
/** How the command reads in a shell, for the execution row. */
|
|
35
|
+
function displayCommand(command: string | string[]): string {
|
|
36
|
+
if (typeof command === "string") return command;
|
|
37
|
+
return command
|
|
38
|
+
.map((arg) =>
|
|
39
|
+
SAFE_ARG.test(arg) ? arg : `'${arg.replace(/'/g, `'"'"'`)}'`,
|
|
40
|
+
)
|
|
41
|
+
.join(" ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const run = action({
|
|
45
|
+
args: {
|
|
46
|
+
machineId: v.string(),
|
|
47
|
+
ownerId: v.optional(v.string()),
|
|
48
|
+
/** A shell command line, or an argv array that is quoted for you. */
|
|
49
|
+
command: v.union(v.string(), v.array(v.string())),
|
|
50
|
+
/** Absolute working directory. A missing one fails the command. */
|
|
51
|
+
cwd: v.optional(v.string()),
|
|
52
|
+
/** Extra environment for this command only. Not stored. */
|
|
53
|
+
env: v.optional(v.record(v.string(), v.string())),
|
|
54
|
+
/** Kill the command after this many ms. Default 540s, capped at 570s. */
|
|
55
|
+
timeoutMs: v.optional(v.number()),
|
|
56
|
+
},
|
|
57
|
+
returns: v.object({
|
|
58
|
+
executionId: v.id("executions"),
|
|
59
|
+
exitCode: v.number(),
|
|
60
|
+
success: v.boolean(),
|
|
61
|
+
stdout: v.string(),
|
|
62
|
+
stderr: v.string(),
|
|
63
|
+
}),
|
|
64
|
+
handler: async (
|
|
65
|
+
ctx,
|
|
66
|
+
args,
|
|
67
|
+
): Promise<{
|
|
68
|
+
executionId: Id<"executions">;
|
|
69
|
+
exitCode: number;
|
|
70
|
+
success: boolean;
|
|
71
|
+
stdout: string;
|
|
72
|
+
stderr: string;
|
|
73
|
+
}> => {
|
|
74
|
+
// Validate before anything is recorded or sent.
|
|
75
|
+
const timeout = boundedTimeout(
|
|
76
|
+
args.timeoutMs,
|
|
77
|
+
DEFAULT_EXEC_TIMEOUT_MS,
|
|
78
|
+
MAX_EXEC_TIMEOUT_MS,
|
|
79
|
+
"timeoutMs",
|
|
80
|
+
);
|
|
81
|
+
if (args.cwd !== undefined) requireAbsolutePath(args.cwd);
|
|
82
|
+
const line = displayCommand(args.command);
|
|
83
|
+
requireNonEmpty(line, "command");
|
|
84
|
+
const machine = await requireMachine(ctx, args.machineId, args.ownerId);
|
|
85
|
+
|
|
86
|
+
const executionId: Id<"executions"> = await ctx.runMutation(
|
|
87
|
+
internal.executions.begin,
|
|
88
|
+
{
|
|
89
|
+
machineId: machine.machineId,
|
|
90
|
+
ownerId: machine.ownerId,
|
|
91
|
+
command: truncate(line, MAX_STORED_COMMAND),
|
|
92
|
+
cwd: args.cwd,
|
|
93
|
+
timeoutMs: timeout,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
try {
|
|
97
|
+
const result = await withBoxd(ctx, (boxd) =>
|
|
98
|
+
boxd.machines.exec(machine.machineId, {
|
|
99
|
+
command: args.command,
|
|
100
|
+
cwd: args.cwd,
|
|
101
|
+
env: args.env,
|
|
102
|
+
timeout,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
await ctx.runMutation(internal.executions.finish, {
|
|
106
|
+
executionId,
|
|
107
|
+
status: "completed",
|
|
108
|
+
exitCode: result.exitCode,
|
|
109
|
+
stdout: truncate(result.stdout, MAX_STORED_OUTPUT),
|
|
110
|
+
stderr: truncate(result.stderr, MAX_STORED_OUTPUT),
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
executionId,
|
|
114
|
+
exitCode: result.exitCode,
|
|
115
|
+
success: result.success,
|
|
116
|
+
stdout: truncate(result.stdout, MAX_RETURNED_OUTPUT),
|
|
117
|
+
stderr: truncate(result.stderr, MAX_RETURNED_OUTPUT),
|
|
118
|
+
};
|
|
119
|
+
} catch (error) {
|
|
120
|
+
await ctx.runMutation(internal.executions.finish, {
|
|
121
|
+
executionId,
|
|
122
|
+
status: "failed",
|
|
123
|
+
error: errorMessage(error),
|
|
124
|
+
});
|
|
125
|
+
if (isNotFound(error)) {
|
|
126
|
+
// boxd no longer has the machine, e.g. after its auto-destroy timer.
|
|
127
|
+
await ctx.runMutation(internal.machines.markDestroyed, {
|
|
128
|
+
machineId: machine.machineId,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
});
|