@byollm/server 0.1.0-alpha.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 +146 -0
- package/dist/chunk-7RKXFPBZ.js +72 -0
- package/dist/chunk-7RKXFPBZ.js.map +1 -0
- package/dist/chunk-HL6EYHQ7.js +422 -0
- package/dist/chunk-HL6EYHQ7.js.map +1 -0
- package/dist/delivery-36nIe-b3.d.ts +73 -0
- package/dist/handlers-D7lWfwno.d.ts +52 -0
- package/dist/index.d.ts +223 -0
- package/dist/index.js +634 -0
- package/dist/index.js.map +1 -0
- package/dist/next.d.ts +33 -0
- package/dist/next.js +17 -0
- package/dist/next.js.map +1 -0
- package/dist/store-D23N6iiP.d.ts +255 -0
- package/dist/supabase/index.d.ts +30 -0
- package/dist/supabase/index.js +414 -0
- package/dist/supabase/index.js.map +1 -0
- package/package.json +52 -0
- package/supabase/migrations/20260809000000_byollm_runner.sql +507 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Of Tomorrow, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
> [!WARNING]
|
|
2
|
+
> **Alpha 0.1.0 — under active development. Don't use this yet.**
|
|
3
|
+
>
|
|
4
|
+
> > Install it deliberately: `npm install @byollm/server@alpha`.
|
|
5
|
+
> The protocol is v0 and **will** change without a deprecation path, this has
|
|
6
|
+
> never run outside its own test suite, and nothing here has production miles.
|
|
7
|
+
> Read it, take the ideas, tell us what's wrong — but don't put it in front of
|
|
8
|
+
> your users.
|
|
9
|
+
>
|
|
10
|
+
> npm assigns `latest` on a first publish and won't let it be removed, so the
|
|
11
|
+
> alpha is also `latest`; the version is marked deprecated so every install
|
|
12
|
+
> says so out loud.
|
|
13
|
+
|
|
14
|
+
# `@byollm/server`
|
|
15
|
+
|
|
16
|
+
What app developers drop into their backend: framework-agnostic protocol
|
|
17
|
+
handlers, a reference in-memory store, a Next.js mount, and a Supabase
|
|
18
|
+
adapter.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @byollm/server
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The three-file integration
|
|
25
|
+
|
|
26
|
+
**1. Mount the protocol.**
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// app/api/byollm/[...route]/route.ts
|
|
30
|
+
import { createHandler } from "@byollm/server/next";
|
|
31
|
+
import { store } from "@/lib/byollm";
|
|
32
|
+
|
|
33
|
+
export const { POST } = createHandler({
|
|
34
|
+
store,
|
|
35
|
+
verificationUrl: "https://your-app.com/settings/runners",
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**2. Pick a store.**
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// lib/byollm.ts
|
|
43
|
+
import { ByollmApp, MemoryStore } from "@byollm/server";
|
|
44
|
+
|
|
45
|
+
export const store = new MemoryStore();
|
|
46
|
+
export const app = new ByollmApp({ store });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**3. Enqueue.**
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const job = await app.enqueue({
|
|
53
|
+
kind: "llm.generate",
|
|
54
|
+
audience: "self", // this user's own machine only — the default
|
|
55
|
+
owner: userId,
|
|
56
|
+
payload: { prompt: `Summarize this transcript:\n\n${transcript}` },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { outcome } = await job.result({
|
|
60
|
+
timeoutMs: 120_000,
|
|
61
|
+
onNoRunner: () => runOnHostedModel(transcript),
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`result()` is sugar over your delivery channel with a timeout and a
|
|
66
|
+
`noRunnerAvailable` path — never a bare promise that hangs forever. If nobody
|
|
67
|
+
is online to run the job, you find out and can fall back.
|
|
68
|
+
|
|
69
|
+
## The approval page
|
|
70
|
+
|
|
71
|
+
Pairing is a device-code exchange, so you need one page where a signed-in user
|
|
72
|
+
types the code their daemon showed them:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// The owner comes from YOUR session. A daemon can never assert who it is.
|
|
76
|
+
const runner = await app.approvePairing({
|
|
77
|
+
userCode: formData.get("code"),
|
|
78
|
+
owner: session.userId,
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`app.pendingPairing(code)` tells you what they are about to approve — machine
|
|
83
|
+
label, platform, and which models it is offering — so the page can show it.
|
|
84
|
+
|
|
85
|
+
## Stores
|
|
86
|
+
|
|
87
|
+
| Store | For |
|
|
88
|
+
| --------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `MemoryStore` | Tests, demos, single-process apps. The reference implementation the conformance kit certifies first. |
|
|
90
|
+
| `supabaseStore` | Postgres via Supabase: migrations, an RLS-scoped claim RPC with `FOR UPDATE SKIP LOCKED`, and Realtime delivery. |
|
|
91
|
+
| yours | Implement `JobStore` + `RunnerStore` and run `@byollm/conformance` against it. |
|
|
92
|
+
|
|
93
|
+
### Supabase
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import {
|
|
97
|
+
supabaseStore,
|
|
98
|
+
supabaseRealtimeDelivery,
|
|
99
|
+
} from "@byollm/server/supabase";
|
|
100
|
+
|
|
101
|
+
const store = supabaseStore({ client: serviceRoleClient });
|
|
102
|
+
const app = new ByollmApp({
|
|
103
|
+
store,
|
|
104
|
+
delivery: supabaseRealtimeDelivery(serviceRoleClient),
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Copy `supabase/migrations/*.sql` into your project's migrations. It ships the
|
|
109
|
+
`byollm_*` tables, RLS policies, the atomic claim RPC, the dependency-unblock
|
|
110
|
+
trigger and the expiry sweep.
|
|
111
|
+
|
|
112
|
+
The protocol handler needs the **service role** key: a runner authenticates
|
|
113
|
+
with its own bearer token, which is not a Supabase session. RLS still governs
|
|
114
|
+
everything the browser does.
|
|
115
|
+
|
|
116
|
+
## Two things the API makes you confront
|
|
117
|
+
|
|
118
|
+
**Community results are untrusted.** A `named`/`public` result came from
|
|
119
|
+
someone else's machine and can be anything. Every result carries provenance,
|
|
120
|
+
and `untrusted` is derived from the audience — you cannot mark volunteer
|
|
121
|
+
output as first-party:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const { outcome, provenance } = await app.result(jobId);
|
|
125
|
+
if (provenance?.untrusted) {
|
|
126
|
+
// Do not render as trusted HTML. Do not feed to a privileged step.
|
|
127
|
+
// Disclose where it came from.
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Jobs can depend on each other.** `dependsOn: [jobId]` keeps a job
|
|
132
|
+
unclaimable until its dependencies are `ok`. One field and one claim
|
|
133
|
+
predicate, not a DAG engine — so the two halves of a piece of work can land on
|
|
134
|
+
two different people's machines, in order, without your app orchestrating the
|
|
135
|
+
wait.
|
|
136
|
+
|
|
137
|
+
## Certifying an adapter
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npx byollm-certify ./my-target.js
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A server is byollm-compatible when the kit passes. See
|
|
144
|
+
[`@byollm/conformance`](../conformance).
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/delivery.ts
|
|
2
|
+
var NoRunnerAvailableError = class extends Error {
|
|
3
|
+
constructor(jobId, reason) {
|
|
4
|
+
super(
|
|
5
|
+
`no runner is available to execute job ${jobId} (${reason}). Fall back to a hosted model, or prompt the user to start their runner.`
|
|
6
|
+
);
|
|
7
|
+
this.jobId = jobId;
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
jobId;
|
|
11
|
+
reason;
|
|
12
|
+
name = "NoRunnerAvailableError";
|
|
13
|
+
};
|
|
14
|
+
var ResultTimeoutError = class extends Error {
|
|
15
|
+
constructor(jobId, timeoutMs) {
|
|
16
|
+
super(`job ${jobId} did not finish within ${String(timeoutMs)}ms`);
|
|
17
|
+
this.jobId = jobId;
|
|
18
|
+
this.timeoutMs = timeoutMs;
|
|
19
|
+
}
|
|
20
|
+
jobId;
|
|
21
|
+
timeoutMs;
|
|
22
|
+
name = "ResultTimeoutError";
|
|
23
|
+
};
|
|
24
|
+
var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
25
|
+
var POLL_INTERVAL_MS = 500;
|
|
26
|
+
var NO_RUNNER_GRACE_MS = 1e4;
|
|
27
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
var PollingDelivery = class {
|
|
29
|
+
#deps;
|
|
30
|
+
constructor(deps) {
|
|
31
|
+
this.#deps = deps;
|
|
32
|
+
}
|
|
33
|
+
async waitFor(jobId, options = {}) {
|
|
34
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
35
|
+
const sleep = this.#deps.sleep ?? defaultSleep;
|
|
36
|
+
const now = this.#deps.now ?? Date.now;
|
|
37
|
+
const graceMs = this.#deps.graceMs ?? NO_RUNNER_GRACE_MS;
|
|
38
|
+
const started = now();
|
|
39
|
+
let noRunnerSince = null;
|
|
40
|
+
for (; ; ) {
|
|
41
|
+
options.signal?.throwIfAborted();
|
|
42
|
+
const current = await this.#deps.read(jobId);
|
|
43
|
+
if (current && isTerminalState(current.state)) return current;
|
|
44
|
+
const availability = await this.#deps.availability(jobId);
|
|
45
|
+
if (availability.available || availability.blocked) {
|
|
46
|
+
noRunnerSince = null;
|
|
47
|
+
} else {
|
|
48
|
+
noRunnerSince ??= now();
|
|
49
|
+
if (now() - noRunnerSince >= graceMs) {
|
|
50
|
+
const reason = availability.reason ?? "no-runner-online";
|
|
51
|
+
const substitute = await options.onNoRunner?.(reason);
|
|
52
|
+
if (substitute) return substitute;
|
|
53
|
+
throw new NoRunnerAvailableError(jobId, reason);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (now() - started >= timeoutMs) {
|
|
57
|
+
throw new ResultTimeoutError(jobId, timeoutMs);
|
|
58
|
+
}
|
|
59
|
+
await sleep(POLL_INTERVAL_MS);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
function isTerminalState(state) {
|
|
64
|
+
return state === "ok" || state === "error" || state === "canceled" || state === "expired";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
NoRunnerAvailableError,
|
|
69
|
+
ResultTimeoutError,
|
|
70
|
+
PollingDelivery
|
|
71
|
+
};
|
|
72
|
+
//# sourceMappingURL=chunk-7RKXFPBZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/delivery.ts"],"sourcesContent":["import type { DeliveredResult } from \"@byollm/protocol\";\n\n/** Why a wait ended without a result. */\nexport class NoRunnerAvailableError extends Error {\n override readonly name = \"NoRunnerAvailableError\";\n constructor(\n readonly jobId: string,\n readonly reason: string,\n ) {\n super(\n `no runner is available to execute job ${jobId} (${reason}). ` +\n `Fall back to a hosted model, or prompt the user to start their runner.`,\n );\n }\n}\n\n/** The wait exceeded its timeout while a runner was still plausibly working. */\nexport class ResultTimeoutError extends Error {\n override readonly name = \"ResultTimeoutError\";\n constructor(\n readonly jobId: string,\n readonly timeoutMs: number,\n ) {\n super(`job ${jobId} did not finish within ${String(timeoutMs)}ms`);\n }\n}\n\nexport interface WaitOptions {\n /** Give up after this long. Default 5 minutes. */\n readonly timeoutMs?: number;\n /**\n * Called instead of throwing when no runner can take the job. Return a\n * substitute result (a hosted-model answer, say) and the wait resolves with\n * it; return nothing and {@link NoRunnerAvailableError} is thrown.\n */\n readonly onNoRunner?: (\n reason: string,\n ) => DeliveredResult | undefined | Promise<DeliveredResult | undefined>;\n /** Abort the wait. */\n readonly signal?: AbortSignal;\n}\n\n/**\n * How an app learns a job finished.\n *\n * byollm_003 Rev 1 is explicit that this is a *channel* — webhook, Realtime\n * subscription, or poll — and never an implied in-request `await`. The\n * polling implementation below is the portable default; the Supabase adapter\n * substitutes Realtime for the same interface.\n */\nexport interface ResultDelivery {\n waitFor(jobId: string, options?: WaitOptions): Promise<DeliveredResult>;\n}\n\nexport interface PollingDeliveryDeps {\n /** Current state of the job, or null if unknown. */\n readonly read: (jobId: string) => Promise<DeliveredResult | null>;\n /** Whether a runner could still take this job. */\n readonly availability: (\n jobId: string,\n ) => Promise<{ available: boolean; reason?: string; blocked: boolean }>;\n readonly sleep?: (ms: number) => Promise<void>;\n /**\n * Injectable clock. It must advance in step with {@link sleep}: a test that\n * stubs one and not the other gets a loop whose grace window never elapses.\n */\n readonly now?: () => number;\n /**\n * How long a sustained no-runner signal must persist before it is believed.\n * Defaults to {@link NO_RUNNER_GRACE_MS}.\n */\n readonly graceMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 5 * 60_000;\nconst POLL_INTERVAL_MS = 500;\n/**\n * How long to let a job sit with no available runner before giving up.\n *\n * Not zero: a daemon restarting, or one whose heartbeat is momentarily late,\n * would otherwise fail every job in flight. The signal has to be sustained\n * before it is believed.\n */\nconst NO_RUNNER_GRACE_MS = 10_000;\n\nconst defaultSleep = (ms: number): Promise<void> =>\n new Promise((resolve) => setTimeout(resolve, ms));\n\n/**\n * The portable delivery channel: poll the store until the job is terminal.\n *\n * Correct everywhere and adequate for most apps. An adapter with a push\n * channel should replace it — see the Supabase adapter's Realtime delivery.\n */\nexport class PollingDelivery implements ResultDelivery {\n readonly #deps: PollingDeliveryDeps;\n\n constructor(deps: PollingDeliveryDeps) {\n this.#deps = deps;\n }\n\n async waitFor(\n jobId: string,\n options: WaitOptions = {},\n ): Promise<DeliveredResult> {\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const sleep = this.#deps.sleep ?? defaultSleep;\n const now = this.#deps.now ?? Date.now;\n const graceMs = this.#deps.graceMs ?? NO_RUNNER_GRACE_MS;\n const started = now();\n let noRunnerSince: number | null = null;\n\n for (;;) {\n options.signal?.throwIfAborted();\n\n const current = await this.#deps.read(jobId);\n if (current && isTerminalState(current.state)) return current;\n\n const availability = await this.#deps.availability(jobId);\n if (availability.available || availability.blocked) {\n // `blocked` means the job is waiting on a dependency, which is not the\n // same event as \"nobody can run this\" ({@link MUSTS.NO_RUNNER_SIGNAL}).\n noRunnerSince = null;\n } else {\n noRunnerSince ??= now();\n if (now() - noRunnerSince >= graceMs) {\n const reason = availability.reason ?? \"no-runner-online\";\n const substitute = await options.onNoRunner?.(reason);\n if (substitute) return substitute;\n throw new NoRunnerAvailableError(jobId, reason);\n }\n }\n\n if (now() - started >= timeoutMs) {\n throw new ResultTimeoutError(jobId, timeoutMs);\n }\n await sleep(POLL_INTERVAL_MS);\n }\n }\n}\n\nfunction isTerminalState(state: string): boolean {\n return (\n state === \"ok\" ||\n state === \"error\" ||\n state === \"canceled\" ||\n state === \"expired\"\n );\n}\n"],"mappings":";AAGO,IAAM,yBAAN,cAAqC,MAAM;AAAA,EAEhD,YACW,OACA,QACT;AACA;AAAA,MACE,yCAAyC,KAAK,KAAK,MAAM;AAAA,IAE3D;AANS;AACA;AAAA,EAMX;AAAA,EAPW;AAAA,EACA;AAAA,EAHO,OAAO;AAU3B;AAGO,IAAM,qBAAN,cAAiC,MAAM;AAAA,EAE5C,YACW,OACA,WACT;AACA,UAAM,OAAO,KAAK,0BAA0B,OAAO,SAAS,CAAC,IAAI;AAHxD;AACA;AAAA,EAGX;AAAA,EAJW;AAAA,EACA;AAAA,EAHO,OAAO;AAO3B;AAiDA,IAAM,qBAAqB,IAAI;AAC/B,IAAM,mBAAmB;AAQzB,IAAM,qBAAqB;AAE3B,IAAM,eAAe,CAAC,OACpB,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAQ3C,IAAM,kBAAN,MAAgD;AAAA,EAC5C;AAAA,EAET,YAAY,MAA2B;AACrC,SAAK,QAAQ;AAAA,EACf;AAAA,EAEA,MAAM,QACJ,OACA,UAAuB,CAAC,GACE;AAC1B,UAAM,YAAY,QAAQ,aAAa;AACvC,UAAM,QAAQ,KAAK,MAAM,SAAS;AAClC,UAAM,MAAM,KAAK,MAAM,OAAO,KAAK;AACnC,UAAM,UAAU,KAAK,MAAM,WAAW;AACtC,UAAM,UAAU,IAAI;AACpB,QAAI,gBAA+B;AAEnC,eAAS;AACP,cAAQ,QAAQ,eAAe;AAE/B,YAAM,UAAU,MAAM,KAAK,MAAM,KAAK,KAAK;AAC3C,UAAI,WAAW,gBAAgB,QAAQ,KAAK,EAAG,QAAO;AAEtD,YAAM,eAAe,MAAM,KAAK,MAAM,aAAa,KAAK;AACxD,UAAI,aAAa,aAAa,aAAa,SAAS;AAGlD,wBAAgB;AAAA,MAClB,OAAO;AACL,0BAAkB,IAAI;AACtB,YAAI,IAAI,IAAI,iBAAiB,SAAS;AACpC,gBAAM,SAAS,aAAa,UAAU;AACtC,gBAAM,aAAa,MAAM,QAAQ,aAAa,MAAM;AACpD,cAAI,WAAY,QAAO;AACvB,gBAAM,IAAI,uBAAuB,OAAO,MAAM;AAAA,QAChD;AAAA,MACF;AAEA,UAAI,IAAI,IAAI,WAAW,WAAW;AAChC,cAAM,IAAI,mBAAmB,OAAO,SAAS;AAAA,MAC/C;AACA,YAAM,MAAM,gBAAgB;AAAA,IAC9B;AAAA,EACF;AACF;AAEA,SAAS,gBAAgB,OAAwB;AAC/C,SACE,UAAU,QACV,UAAU,WACV,UAAU,cACV,UAAU;AAEd;","names":[]}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// src/ids.ts
|
|
2
|
+
import {
|
|
3
|
+
createHash,
|
|
4
|
+
randomBytes,
|
|
5
|
+
randomUUID,
|
|
6
|
+
timingSafeEqual
|
|
7
|
+
} from "crypto";
|
|
8
|
+
var USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRTWXYZ2346789";
|
|
9
|
+
function generateDeviceCode() {
|
|
10
|
+
return randomBytes(32).toString("base64url");
|
|
11
|
+
}
|
|
12
|
+
function generateRunnerToken() {
|
|
13
|
+
return randomBytes(32).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
function generateRunnerId() {
|
|
16
|
+
return `runner_${randomUUID()}`;
|
|
17
|
+
}
|
|
18
|
+
function generateJobId() {
|
|
19
|
+
return `job_${randomUUID()}`;
|
|
20
|
+
}
|
|
21
|
+
function generateUserCode() {
|
|
22
|
+
const chars = [];
|
|
23
|
+
while (chars.length < 8) {
|
|
24
|
+
for (const byte of randomBytes(16)) {
|
|
25
|
+
const limit = 256 - 256 % USER_CODE_ALPHABET.length;
|
|
26
|
+
if (byte >= limit) continue;
|
|
27
|
+
const symbol = USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length];
|
|
28
|
+
if (symbol === void 0) continue;
|
|
29
|
+
chars.push(symbol);
|
|
30
|
+
if (chars.length === 8) break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return `${chars.slice(0, 4).join("")}-${chars.slice(4).join("")}`;
|
|
34
|
+
}
|
|
35
|
+
function hashSecret(secret) {
|
|
36
|
+
return createHash("sha256").update(secret, "utf8").digest("hex");
|
|
37
|
+
}
|
|
38
|
+
function secretsMatch(aHex, bHex) {
|
|
39
|
+
if (aHex.length !== bHex.length) return false;
|
|
40
|
+
return timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/handlers.ts
|
|
44
|
+
import {
|
|
45
|
+
ClaimRequest,
|
|
46
|
+
ERROR_STATUS,
|
|
47
|
+
HeartbeatRequest,
|
|
48
|
+
PairRequest,
|
|
49
|
+
PROTOCOL_VERSION,
|
|
50
|
+
ReleaseRequest,
|
|
51
|
+
ResultRequest,
|
|
52
|
+
provenanceFor
|
|
53
|
+
} from "@byollm/protocol";
|
|
54
|
+
var DEFAULTS = {
|
|
55
|
+
leaseMs: 6e4,
|
|
56
|
+
pairingTtlMs: 10 * 6e4,
|
|
57
|
+
pollIntervalMs: 2e3
|
|
58
|
+
};
|
|
59
|
+
function fail(error, message, retryAfterSeconds) {
|
|
60
|
+
return {
|
|
61
|
+
status: ERROR_STATUS[error],
|
|
62
|
+
body: {
|
|
63
|
+
error,
|
|
64
|
+
message,
|
|
65
|
+
...retryAfterSeconds === void 0 ? {} : { retryAfter: retryAfterSeconds }
|
|
66
|
+
},
|
|
67
|
+
...retryAfterSeconds === void 0 ? {} : { retryAfterSeconds }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function ok(body) {
|
|
71
|
+
return { status: 200, body };
|
|
72
|
+
}
|
|
73
|
+
var ByollmHandlers = class {
|
|
74
|
+
#store;
|
|
75
|
+
#verificationUrl;
|
|
76
|
+
#leaseMs;
|
|
77
|
+
#pairingTtlMs;
|
|
78
|
+
#pollIntervalMs;
|
|
79
|
+
#now;
|
|
80
|
+
constructor(config) {
|
|
81
|
+
this.#store = config.store;
|
|
82
|
+
this.#verificationUrl = config.verificationUrl;
|
|
83
|
+
this.#leaseMs = config.leaseMs ?? DEFAULTS.leaseMs;
|
|
84
|
+
this.#pairingTtlMs = config.pairingTtlMs ?? DEFAULTS.pairingTtlMs;
|
|
85
|
+
this.#pollIntervalMs = config.pollIntervalMs ?? DEFAULTS.pollIntervalMs;
|
|
86
|
+
this.#now = config.now ?? Date.now;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Dispatch one protocol call.
|
|
90
|
+
*
|
|
91
|
+
* @param endpoint - which of the five, already routed from the path
|
|
92
|
+
* @param body - the parsed JSON request body, untrusted
|
|
93
|
+
* @param bearer - the `Authorization: Bearer` value, if any
|
|
94
|
+
*/
|
|
95
|
+
async handle(endpoint, body, bearer) {
|
|
96
|
+
switch (endpoint) {
|
|
97
|
+
case "pair":
|
|
98
|
+
return this.#pair(body);
|
|
99
|
+
case "claim":
|
|
100
|
+
return this.#authed(bearer, body, ClaimRequest, this.#claim.bind(this));
|
|
101
|
+
case "heartbeat":
|
|
102
|
+
return this.#authed(
|
|
103
|
+
bearer,
|
|
104
|
+
body,
|
|
105
|
+
HeartbeatRequest,
|
|
106
|
+
this.#heartbeat.bind(this),
|
|
107
|
+
{ allowRevoked: true }
|
|
108
|
+
);
|
|
109
|
+
case "result":
|
|
110
|
+
return this.#authed(
|
|
111
|
+
bearer,
|
|
112
|
+
body,
|
|
113
|
+
ResultRequest,
|
|
114
|
+
this.#result.bind(this)
|
|
115
|
+
);
|
|
116
|
+
case "release":
|
|
117
|
+
return this.#authed(
|
|
118
|
+
bearer,
|
|
119
|
+
body,
|
|
120
|
+
ReleaseRequest,
|
|
121
|
+
this.#release.bind(this)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Shared preamble for the four authenticated endpoints: resolve the bearer
|
|
127
|
+
* token to a runner, reject a revoked one, and parse the body.
|
|
128
|
+
*
|
|
129
|
+
* The token→runner lookup happens before schema validation so a stranger
|
|
130
|
+
* probing the endpoint learns nothing about the wire format.
|
|
131
|
+
*/
|
|
132
|
+
async #authed(bearer, body, schema, run, options = {}) {
|
|
133
|
+
if (bearer === void 0 || bearer.length === 0) {
|
|
134
|
+
return fail("unauthorized", "a runner token is required");
|
|
135
|
+
}
|
|
136
|
+
const runner = await this.#store.getRunnerByTokenHash(hashSecret(bearer));
|
|
137
|
+
if (!runner) {
|
|
138
|
+
return fail("unauthorized", "this runner token is not recognised");
|
|
139
|
+
}
|
|
140
|
+
if (runner.revokedAt !== null && options.allowRevoked !== true) {
|
|
141
|
+
return fail("revoked", "this runner has been revoked by its owner");
|
|
142
|
+
}
|
|
143
|
+
const parsed = schema.safeParse(body);
|
|
144
|
+
if (!parsed.success || parsed.data === void 0) {
|
|
145
|
+
return fail("bad-request", "request body failed schema validation");
|
|
146
|
+
}
|
|
147
|
+
return run(parsed.data, runner);
|
|
148
|
+
}
|
|
149
|
+
// -- 1. pair --------------------------------------------------------------
|
|
150
|
+
async #pair(body) {
|
|
151
|
+
const parsed = PairRequest.safeParse(body);
|
|
152
|
+
if (!parsed.success) {
|
|
153
|
+
return fail("bad-request", "pair request failed schema validation");
|
|
154
|
+
}
|
|
155
|
+
const request = parsed.data;
|
|
156
|
+
const now = this.#now();
|
|
157
|
+
if (request.action === "start") {
|
|
158
|
+
const deviceCode = generateDeviceCode();
|
|
159
|
+
const userCode = generateUserCode();
|
|
160
|
+
const expiresAt = now + this.#pairingTtlMs;
|
|
161
|
+
await this.#store.createPairing({
|
|
162
|
+
deviceCodeHash: hashSecret(deviceCode),
|
|
163
|
+
userCode,
|
|
164
|
+
state: "pending",
|
|
165
|
+
owner: null,
|
|
166
|
+
runnerId: null,
|
|
167
|
+
runnerTokenOnce: null,
|
|
168
|
+
label: request.daemon.label,
|
|
169
|
+
platform: request.daemon.platform,
|
|
170
|
+
daemonVersion: request.daemon.version,
|
|
171
|
+
capabilities: request.capabilities,
|
|
172
|
+
expiresAt,
|
|
173
|
+
createdAt: now
|
|
174
|
+
});
|
|
175
|
+
const response = {
|
|
176
|
+
deviceCode,
|
|
177
|
+
userCode,
|
|
178
|
+
verificationUrl: this.#verificationUrl,
|
|
179
|
+
expiresAt,
|
|
180
|
+
pollIntervalMs: this.#pollIntervalMs
|
|
181
|
+
};
|
|
182
|
+
return ok(response);
|
|
183
|
+
}
|
|
184
|
+
const pairing = await this.#store.getPairingByDeviceCodeHash(
|
|
185
|
+
hashSecret(request.deviceCode)
|
|
186
|
+
);
|
|
187
|
+
if (!pairing) {
|
|
188
|
+
return fail("not-found", "unknown device code");
|
|
189
|
+
}
|
|
190
|
+
if (pairing.state === "denied") {
|
|
191
|
+
return ok({ status: "denied" });
|
|
192
|
+
}
|
|
193
|
+
if (pairing.expiresAt <= now && pairing.state === "pending") {
|
|
194
|
+
return ok({ status: "expired" });
|
|
195
|
+
}
|
|
196
|
+
if (pairing.state === "approved" && pairing.runnerTokenOnce !== null && pairing.runnerId !== null && pairing.owner !== null) {
|
|
197
|
+
const response = {
|
|
198
|
+
status: "approved",
|
|
199
|
+
runnerToken: pairing.runnerTokenOnce,
|
|
200
|
+
runnerId: pairing.runnerId,
|
|
201
|
+
owner: pairing.owner
|
|
202
|
+
};
|
|
203
|
+
await this.#store.consumePairingToken(pairing.deviceCodeHash);
|
|
204
|
+
return ok(response);
|
|
205
|
+
}
|
|
206
|
+
if (pairing.state === "approved") {
|
|
207
|
+
return fail("not-found", "this pairing has already been collected");
|
|
208
|
+
}
|
|
209
|
+
return ok({ status: "pending" });
|
|
210
|
+
}
|
|
211
|
+
// -- 2. claim -------------------------------------------------------------
|
|
212
|
+
async #claim(request, runner) {
|
|
213
|
+
if (request.runnerId !== runner.id) {
|
|
214
|
+
return fail("unauthorized", "runner id does not match the bearer token");
|
|
215
|
+
}
|
|
216
|
+
const now = this.#now();
|
|
217
|
+
const jobs = await this.#store.claim({
|
|
218
|
+
runnerId: runner.id,
|
|
219
|
+
runnerOwner: runner.owner,
|
|
220
|
+
capabilities: request.capabilities,
|
|
221
|
+
max: request.max,
|
|
222
|
+
leaseMs: this.#leaseMs,
|
|
223
|
+
now
|
|
224
|
+
});
|
|
225
|
+
const response = {
|
|
226
|
+
jobs: jobs.map((job) => ({
|
|
227
|
+
id: job.id,
|
|
228
|
+
kind: job.kind,
|
|
229
|
+
payload: job.payload,
|
|
230
|
+
audience: job.audience,
|
|
231
|
+
owner: job.owner,
|
|
232
|
+
...job.audienceAllow === void 0 ? {} : { audienceAllow: [...job.audienceAllow] },
|
|
233
|
+
lease: job.lease ?? {
|
|
234
|
+
runnerId: runner.id,
|
|
235
|
+
expiresAt: now + this.#leaseMs
|
|
236
|
+
}
|
|
237
|
+
})),
|
|
238
|
+
leaseMs: this.#leaseMs
|
|
239
|
+
};
|
|
240
|
+
return ok(response);
|
|
241
|
+
}
|
|
242
|
+
// -- 3. heartbeat ---------------------------------------------------------
|
|
243
|
+
async #heartbeat(request, runner) {
|
|
244
|
+
if (request.runnerId !== runner.id) {
|
|
245
|
+
return fail("unauthorized", "runner id does not match the bearer token");
|
|
246
|
+
}
|
|
247
|
+
const now = this.#now();
|
|
248
|
+
const revoked = runner.revokedAt !== null;
|
|
249
|
+
if (revoked) {
|
|
250
|
+
const held = await this.#store.listClaimedBy(runner.id);
|
|
251
|
+
const response2 = {
|
|
252
|
+
revoked: true,
|
|
253
|
+
cancel: [],
|
|
254
|
+
leases: [],
|
|
255
|
+
lost: held.map((job) => job.id),
|
|
256
|
+
serverTime: now
|
|
257
|
+
};
|
|
258
|
+
return ok(response2);
|
|
259
|
+
}
|
|
260
|
+
await this.#store.touchRunner({
|
|
261
|
+
runnerId: runner.id,
|
|
262
|
+
capabilities: request.capabilities,
|
|
263
|
+
daemonVersion: request.daemonVersion,
|
|
264
|
+
paused: request.paused,
|
|
265
|
+
now
|
|
266
|
+
});
|
|
267
|
+
const { renewed, lost } = await this.#store.renewLeases({
|
|
268
|
+
runnerId: runner.id,
|
|
269
|
+
jobIds: request.activeJobIds,
|
|
270
|
+
leaseMs: this.#leaseMs,
|
|
271
|
+
now
|
|
272
|
+
});
|
|
273
|
+
const cancel = await this.#store.listCancelRequests(runner.id);
|
|
274
|
+
const response = {
|
|
275
|
+
revoked: false,
|
|
276
|
+
cancel,
|
|
277
|
+
leases: renewed.map((r) => ({ jobId: r.jobId, expiresAt: r.expiresAt })),
|
|
278
|
+
lost: [...lost],
|
|
279
|
+
serverTime: now
|
|
280
|
+
};
|
|
281
|
+
return ok(response);
|
|
282
|
+
}
|
|
283
|
+
// -- 4. result ------------------------------------------------------------
|
|
284
|
+
async #result(request, runner) {
|
|
285
|
+
if (request.runnerId !== runner.id) {
|
|
286
|
+
return fail("unauthorized", "runner id does not match the bearer token");
|
|
287
|
+
}
|
|
288
|
+
const now = this.#now();
|
|
289
|
+
const job = await this.#store.get(request.jobId);
|
|
290
|
+
if (!job) return fail("not-found", "unknown job");
|
|
291
|
+
const provenance = provenanceFor({
|
|
292
|
+
audience: job.audience,
|
|
293
|
+
runnerId: runner.id,
|
|
294
|
+
runnerOwner: runner.owner,
|
|
295
|
+
backendClass: request.backendClass,
|
|
296
|
+
model: request.model
|
|
297
|
+
});
|
|
298
|
+
const { accepted, job: updated } = await this.#store.complete({
|
|
299
|
+
jobId: request.jobId,
|
|
300
|
+
runnerId: runner.id,
|
|
301
|
+
outcome: request.outcome,
|
|
302
|
+
provenance,
|
|
303
|
+
now
|
|
304
|
+
});
|
|
305
|
+
const response = {
|
|
306
|
+
accepted,
|
|
307
|
+
state: updated?.state ?? job.state
|
|
308
|
+
};
|
|
309
|
+
return ok(response);
|
|
310
|
+
}
|
|
311
|
+
// -- 5. release -----------------------------------------------------------
|
|
312
|
+
async #release(request, runner) {
|
|
313
|
+
if (request.runnerId !== runner.id) {
|
|
314
|
+
return fail("unauthorized", "runner id does not match the bearer token");
|
|
315
|
+
}
|
|
316
|
+
const released = await this.#store.release({
|
|
317
|
+
runnerId: runner.id,
|
|
318
|
+
jobIds: request.jobIds,
|
|
319
|
+
reason: request.reason,
|
|
320
|
+
now: this.#now()
|
|
321
|
+
});
|
|
322
|
+
const response = { released };
|
|
323
|
+
return ok(response);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
var SERVED_PROTOCOL_VERSION = PROTOCOL_VERSION;
|
|
327
|
+
|
|
328
|
+
// src/http.ts
|
|
329
|
+
import { ENDPOINTS, PROTOCOL_PREFIX } from "@byollm/protocol";
|
|
330
|
+
var MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
331
|
+
function routeEndpoint(pathname) {
|
|
332
|
+
const index = pathname.lastIndexOf("/");
|
|
333
|
+
const last = index === -1 ? pathname : pathname.slice(index + 1);
|
|
334
|
+
return ENDPOINTS.includes(last) ? last : null;
|
|
335
|
+
}
|
|
336
|
+
function bearerFrom(header) {
|
|
337
|
+
if (!header) return void 0;
|
|
338
|
+
const match = /^Bearer[ ]+(.+)$/i.exec(header.trim());
|
|
339
|
+
return match?.[1];
|
|
340
|
+
}
|
|
341
|
+
function createFetchHandler(config) {
|
|
342
|
+
const handlers = new ByollmHandlers(config);
|
|
343
|
+
return async function handle(request) {
|
|
344
|
+
if (request.method !== "POST") {
|
|
345
|
+
return json(405, {
|
|
346
|
+
error: "bad-request",
|
|
347
|
+
message: "protocol endpoints accept POST only"
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const endpoint = routeEndpoint(new URL(request.url).pathname);
|
|
351
|
+
if (endpoint === null) {
|
|
352
|
+
return json(404, {
|
|
353
|
+
error: "not-found",
|
|
354
|
+
message: `not a ${PROTOCOL_PREFIX} endpoint`
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const declared = request.headers.get("content-length");
|
|
358
|
+
if (declared !== null && Number(declared) > MAX_BODY_BYTES) {
|
|
359
|
+
return json(400, {
|
|
360
|
+
error: "bad-request",
|
|
361
|
+
message: "request body too large"
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
let body;
|
|
365
|
+
try {
|
|
366
|
+
const text = await request.text();
|
|
367
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
368
|
+
return json(400, {
|
|
369
|
+
error: "bad-request",
|
|
370
|
+
message: "request body too large"
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
body = JSON.parse(text);
|
|
374
|
+
} catch {
|
|
375
|
+
return json(400, {
|
|
376
|
+
error: "bad-request",
|
|
377
|
+
message: "request body is not valid JSON"
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const result = await handlers.handle(
|
|
381
|
+
endpoint,
|
|
382
|
+
body,
|
|
383
|
+
bearerFrom(request.headers.get("authorization"))
|
|
384
|
+
);
|
|
385
|
+
const headers = {
|
|
386
|
+
"content-type": "application/json",
|
|
387
|
+
"cache-control": "no-store"
|
|
388
|
+
};
|
|
389
|
+
if (result.retryAfterSeconds !== void 0) {
|
|
390
|
+
headers["retry-after"] = String(result.retryAfterSeconds);
|
|
391
|
+
}
|
|
392
|
+
return new Response(JSON.stringify(result.body), {
|
|
393
|
+
status: result.status,
|
|
394
|
+
headers
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function json(status, body) {
|
|
399
|
+
return new Response(JSON.stringify(body), {
|
|
400
|
+
status,
|
|
401
|
+
headers: {
|
|
402
|
+
"content-type": "application/json",
|
|
403
|
+
"cache-control": "no-store"
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export {
|
|
409
|
+
generateDeviceCode,
|
|
410
|
+
generateRunnerToken,
|
|
411
|
+
generateRunnerId,
|
|
412
|
+
generateJobId,
|
|
413
|
+
generateUserCode,
|
|
414
|
+
hashSecret,
|
|
415
|
+
secretsMatch,
|
|
416
|
+
ByollmHandlers,
|
|
417
|
+
SERVED_PROTOCOL_VERSION,
|
|
418
|
+
routeEndpoint,
|
|
419
|
+
bearerFrom,
|
|
420
|
+
createFetchHandler
|
|
421
|
+
};
|
|
422
|
+
//# sourceMappingURL=chunk-HL6EYHQ7.js.map
|