@blastlauncher/engine 0.0.0-stage → 3.0.0-alpha.1

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.
@@ -0,0 +1,203 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { loadSharedDependencies } from "./dependencies.js";
6
+ import { requiredPeerAdditions, supplied } from "./install-peers.js";
7
+ import { defaultDataDir } from "./storage.js";
8
+ export const npmDependencyInstaller = ({ command, args, cwd, onLog }) => new Promise((done, reject) => {
9
+ const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
10
+ child.stdout.on("data", (chunk) => onLog?.(chunk.toString()));
11
+ child.stderr.on("data", (chunk) => onLog?.(chunk.toString()));
12
+ child.once("error", reject);
13
+ child.once("close", (code, signal) => {
14
+ if (code === 0)
15
+ done();
16
+ else
17
+ reject(new Error(`npm install failed (${signal ?? code})`));
18
+ });
19
+ });
20
+ function stripSuppliedDependencies(dependencies) {
21
+ for (const [name, dependency] of Object.entries(dependencies)) {
22
+ if (supplied(name))
23
+ delete dependencies[name];
24
+ else if (dependency.dependencies)
25
+ stripSuppliedDependencies(dependency.dependencies);
26
+ }
27
+ }
28
+ async function optionalFile(path) {
29
+ try {
30
+ return await readFile(path, "utf8");
31
+ }
32
+ catch (error) {
33
+ if (error.code === "ENOENT")
34
+ return undefined;
35
+ throw error;
36
+ }
37
+ }
38
+ async function inputs(options) {
39
+ const extensionDir = resolve(options.extensionDir);
40
+ const manifest = JSON.parse(await readFile(join(extensionDir, "package.json"), "utf8"));
41
+ if (typeof manifest.name !== "string" || !manifest.name)
42
+ throw new Error("An extension name is required for dependency installation");
43
+ const lock = await optionalFile(join(extensionDir, "package-lock.json"));
44
+ // Sort dependency fields so JSON property order does not invalidate an install.
45
+ const dependencies = Object.fromEntries(Object.entries(manifest.dependencies ?? {}).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
46
+ const baseKey = createHash("sha256")
47
+ // Rebuild installs made before required peers were repaired.
48
+ .update("lean-install-v2\n")
49
+ .update(JSON.stringify(dependencies))
50
+ .update("\n")
51
+ .update(lock ?? "")
52
+ .digest("hex");
53
+ const encoded = encodeURIComponent(manifest.name).replace(/\./g, "%2E");
54
+ const directory = resolve(options.dataDir ?? defaultDataDir(), "deps", encoded);
55
+ const additions = JSON.parse((await optionalFile(join(directory, "peer-additions.json"))) ?? "{}");
56
+ const key = installKey(baseKey, additions);
57
+ return { manifest, dependencies, extensionDir, lock, key, baseKey, directory };
58
+ }
59
+ function installKey(baseKey, additions) {
60
+ return createHash("sha256")
61
+ .update(baseKey)
62
+ .update(JSON.stringify(Object.entries(additions).toSorted(([a], [b]) => a.localeCompare(b))))
63
+ .digest("hex");
64
+ }
65
+ /** Cheap disk-only check; a key is published only after a successful install. */
66
+ export async function checkExtensionDependencies(options) {
67
+ const { directory, key, lock, dependencies } = await inputs(options);
68
+ const needed = (await optionalFile(join(directory, "key"))) !== key;
69
+ const shared = await loadSharedDependencies(options.sharedDependencies);
70
+ const unresolved = [];
71
+ for (const name of Object.keys(dependencies)) {
72
+ if (supplied(name))
73
+ continue;
74
+ if (!needed &&
75
+ (await optionalFile(join(directory, "node_modules", name, "package.json"))) !== undefined)
76
+ continue;
77
+ if (shared?.resolveShared(name))
78
+ continue;
79
+ unresolved.push(name);
80
+ }
81
+ const unique = [...new Set(unresolved)].toSorted();
82
+ return {
83
+ directory,
84
+ key,
85
+ needed,
86
+ unresolved: unique,
87
+ pinned: lock !== undefined,
88
+ installable: unique.every((name) => Object.hasOwn(dependencies, name)),
89
+ };
90
+ }
91
+ /** Installs only production dependencies; never writes to the extension. */
92
+ export async function installExtensionDependencies(options) {
93
+ const input = await inputs(options);
94
+ const status = {
95
+ directory: input.directory,
96
+ key: input.key,
97
+ needed: false,
98
+ pinned: input.lock !== undefined,
99
+ unresolved: [],
100
+ installable: true,
101
+ };
102
+ if ((await optionalFile(join(input.directory, "key"))) === input.key) {
103
+ options.onLog?.("Dependencies are up to date.");
104
+ return status;
105
+ }
106
+ if (!status.pinned)
107
+ options.onLog?.("Warning: no package-lock.json; dependency versions are unpinned.");
108
+ const dependencies = Object.fromEntries(Object.entries(input.dependencies)
109
+ .filter(([name]) => !supplied(name))
110
+ .map(([name, value]) => {
111
+ if (typeof value !== "string")
112
+ throw new Error(`Invalid dependency ${name}`);
113
+ // Local packages retain their source location after the manifest is copied.
114
+ return [
115
+ name,
116
+ value.startsWith("file:") ? `file:${resolve(input.extensionDir, value.slice(5))}` : value,
117
+ ];
118
+ }));
119
+ await rm(input.directory, { recursive: true, force: true });
120
+ await mkdir(input.directory, { recursive: true });
121
+ try {
122
+ await writeFile(join(input.directory, "package.json"), JSON.stringify({
123
+ name: input.manifest.name,
124
+ version: input.manifest.version ?? "0.0.0",
125
+ private: true,
126
+ dependencies,
127
+ }, null, 2));
128
+ if (input.lock !== undefined) {
129
+ const lock = JSON.parse(input.lock);
130
+ // Keep pinned package entries, but align the root with the filtered manifest.
131
+ if (lock.packages?.[""]) {
132
+ lock.packages[""] = {
133
+ name: input.manifest.name,
134
+ version: input.manifest.version ?? "0.0.0",
135
+ dependencies,
136
+ };
137
+ }
138
+ if (lock.packages) {
139
+ for (const path of Object.keys(lock.packages)) {
140
+ // Drop supplied packages and their private graphs at any nesting depth.
141
+ if (path
142
+ .split(/(?:^|\/)node_modules\//)
143
+ .slice(1)
144
+ .some(supplied))
145
+ delete lock.packages[path];
146
+ }
147
+ }
148
+ if (lock.dependencies)
149
+ stripSuppliedDependencies(lock.dependencies);
150
+ await writeFile(join(input.directory, "package-lock.json"), JSON.stringify(lock, null, 2));
151
+ }
152
+ const install = () => (options.installer ?? npmDependencyInstaller)({
153
+ command: process.platform === "win32" ? "npm.cmd" : "npm",
154
+ args: [
155
+ "install",
156
+ "--omit=dev",
157
+ "--omit=peer",
158
+ "--ignore-scripts",
159
+ "--no-audit",
160
+ "--no-fund",
161
+ ],
162
+ cwd: input.directory,
163
+ ...(options.onLog ? { onLog: options.onLog } : {}),
164
+ });
165
+ if (Object.keys(dependencies).length)
166
+ await install();
167
+ const additions = {};
168
+ for (let pass = 0; pass < 2; pass++) {
169
+ const peers = await requiredPeerAdditions(input.directory, input.lock, options.onLog);
170
+ let changed = false;
171
+ for (const [name, range] of Object.entries(peers)) {
172
+ if (name in input.dependencies || additions[name] === range) {
173
+ options.onLog?.(`Warning: unresolved required peer ${name} (${range}).`);
174
+ continue;
175
+ }
176
+ dependencies[name] = additions[name] = range;
177
+ changed = true;
178
+ }
179
+ if (!changed)
180
+ break;
181
+ const path = join(input.directory, "package.json");
182
+ const manifest = JSON.parse(await readFile(path, "utf8"));
183
+ await writeFile(path, JSON.stringify({ ...manifest, dependencies }, null, 2));
184
+ try {
185
+ await install();
186
+ }
187
+ catch (error) {
188
+ options.onLog?.(`Warning: required peer installation failed: ${String(error)}`);
189
+ break;
190
+ }
191
+ }
192
+ for (const [name, range] of Object.entries(await requiredPeerAdditions(input.directory, input.lock)))
193
+ options.onLog?.(`Warning: unresolved required peer ${name} (${range}).`);
194
+ status.key = installKey(input.baseKey, additions);
195
+ await writeFile(join(input.directory, "peer-additions.json"), JSON.stringify(additions));
196
+ await writeFile(join(input.directory, "key"), status.key);
197
+ return status;
198
+ }
199
+ catch (error) {
200
+ await rm(input.directory, { recursive: true, force: true });
201
+ throw error;
202
+ }
203
+ }
@@ -0,0 +1,3 @@
1
+ export declare function supplied(name: string): boolean;
2
+ /** Disk-only peer discovery. npm remains responsible for fetching selected versions. */
3
+ export declare function requiredPeerAdditions(directory: string, lock: string | undefined, onLog?: (message: string) => void): Promise<Record<string, string>>;
@@ -0,0 +1,120 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { Range, minVersion, rcompare, satisfies, valid } from "semver";
4
+ export function supplied(name) {
5
+ return name === "@raycast/api" || /^react(?:\/|$)/.test(name) || name.startsWith("@types/");
6
+ }
7
+ async function manifest(directory) {
8
+ try {
9
+ return JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
10
+ }
11
+ catch (error) {
12
+ if (error.code === "ENOENT")
13
+ return undefined;
14
+ throw error;
15
+ }
16
+ }
17
+ async function* packages(directory) {
18
+ let entries;
19
+ try {
20
+ entries = await readdir(directory, { withFileTypes: true });
21
+ }
22
+ catch (error) {
23
+ if (error.code === "ENOENT")
24
+ return;
25
+ throw error;
26
+ }
27
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
28
+ if (entry.name.startsWith("."))
29
+ continue;
30
+ const path = join(directory, entry.name);
31
+ if (entry.name.startsWith("@")) {
32
+ yield* packages(path);
33
+ }
34
+ else if (entry.isDirectory() || entry.isSymbolicLink()) {
35
+ yield path;
36
+ // Do not follow local-package symlinks into unrelated dependency trees.
37
+ if (entry.isDirectory())
38
+ yield* packages(join(path, "node_modules"));
39
+ }
40
+ }
41
+ }
42
+ function lockedVersions(lock, name) {
43
+ if (lock === undefined)
44
+ return [];
45
+ const parsed = JSON.parse(lock);
46
+ const versions = new Set();
47
+ for (const [path, entry] of Object.entries(parsed.packages ?? {})) {
48
+ if (path === `node_modules/${name}` || path.endsWith(`/node_modules/${name}`)) {
49
+ const version = entry.version;
50
+ if (version && valid(version))
51
+ versions.add(version);
52
+ }
53
+ }
54
+ const visit = (dependencies) => {
55
+ for (const [key, entry] of Object.entries(dependencies)) {
56
+ if (key === name && entry.version && valid(entry.version))
57
+ versions.add(entry.version);
58
+ if (entry.dependencies)
59
+ visit(entry.dependencies);
60
+ }
61
+ };
62
+ visit(parsed.dependencies ?? {});
63
+ return [...versions].toSorted(rcompare);
64
+ }
65
+ /** Intersect comparator sets, including each branch of union ranges. */
66
+ function intersection(ranges) {
67
+ let combinations = [""];
68
+ for (const range of ranges) {
69
+ const sets = new Range(range).set;
70
+ combinations = combinations.flatMap((left) => sets.map((set) => `${left} ${set.map((item) => item.value).join(" ")}`.trim()));
71
+ }
72
+ const possible = combinations.filter((range) => {
73
+ const minimum = minVersion(range);
74
+ return minimum !== null && ranges.every((constraint) => satisfies(minimum, constraint));
75
+ });
76
+ return possible.length ? possible.join(" || ") || "*" : undefined;
77
+ }
78
+ /** Disk-only peer discovery. npm remains responsible for fetching selected versions. */
79
+ export async function requiredPeerAdditions(directory, lock, onLog) {
80
+ const required = new Map();
81
+ for await (const path of packages(join(directory, "node_modules"))) {
82
+ const pkg = await manifest(path);
83
+ for (const [name, range] of Object.entries(pkg?.peerDependencies ?? {})) {
84
+ if (supplied(name) || pkg.peerDependenciesMeta?.[name]?.optional === true)
85
+ continue;
86
+ const peer = required.get(name) ?? { ranges: new Set(), missing: false };
87
+ peer.ranges.add(String(range));
88
+ let installed;
89
+ for (let ancestor = path;; ancestor = dirname(ancestor)) {
90
+ installed = await manifest(join(ancestor, "node_modules", name));
91
+ if (installed || ancestor === directory)
92
+ break;
93
+ }
94
+ try {
95
+ peer.missing ||= !installed?.version || !satisfies(installed.version, String(range));
96
+ }
97
+ catch {
98
+ peer.missing = true;
99
+ }
100
+ required.set(name, peer);
101
+ }
102
+ }
103
+ const additions = {};
104
+ for (const [name, peer] of [...required].toSorted(([a], [b]) => a.localeCompare(b))) {
105
+ if (!peer.missing)
106
+ continue;
107
+ const ranges = [...peer.ranges].toSorted();
108
+ try {
109
+ const range = intersection(ranges);
110
+ if (range === undefined)
111
+ throw new Error("conflicting ranges");
112
+ additions[name] =
113
+ lockedVersions(lock, name).find((version) => satisfies(version, range)) ?? range;
114
+ }
115
+ catch {
116
+ onLog?.(`Warning: cannot resolve required peer ${name} (${ranges.join("; ")}).`);
117
+ }
118
+ }
119
+ return additions;
120
+ }
@@ -0,0 +1,4 @@
1
+ import type { CapabilityHandler } from "./broker.js";
2
+ import { type StorageOptions } from "./storage.js";
3
+ export declare function oauthTokenFile(options: StorageOptions): string;
4
+ export declare function createOAuthHandlers(options: StorageOptions): Record<string, CapabilityHandler>;
package/dist/oauth.js ADDED
@@ -0,0 +1,99 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { defaultDataDir, storageFile } from "./storage.js";
5
+ // Shared by sessions in this host process to serialize read/modify/write cycles.
6
+ const queues = new Map();
7
+ export function oauthTokenFile(options) {
8
+ return storageFile(join(options.dataDir ?? defaultDataDir(), "oauth"), options.extensionName);
9
+ }
10
+ export function createOAuthHandlers(options) {
11
+ const file = oauthTokenFile(options);
12
+ const serialize = (operation) => {
13
+ const next = (queues.get(file) ?? Promise.resolve()).then(operation, operation);
14
+ queues.set(file, next);
15
+ void next
16
+ .finally(() => {
17
+ if (queues.get(file) === next)
18
+ queues.delete(file);
19
+ })
20
+ .catch(() => undefined);
21
+ return next;
22
+ };
23
+ const read = async () => {
24
+ try {
25
+ const value = JSON.parse(await readFile(file, "utf8"));
26
+ if (!value || typeof value !== "object" || Array.isArray(value))
27
+ throw new Error("Invalid OAuth token store");
28
+ return value;
29
+ }
30
+ catch (error) {
31
+ if (error.code === "ENOENT")
32
+ return {};
33
+ throw new Error("Cannot read OAuth token store", { cause: error });
34
+ }
35
+ };
36
+ const write = async (value) => {
37
+ await mkdir(dirname(file), { recursive: true });
38
+ const temporary = `${file}.${randomUUID()}.tmp`;
39
+ try {
40
+ await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
41
+ await rename(temporary, file);
42
+ }
43
+ finally {
44
+ await rm(temporary, { force: true });
45
+ }
46
+ };
47
+ const handler = (operation) => (params) => serialize(async () => {
48
+ if (!params ||
49
+ typeof params !== "object" ||
50
+ Array.isArray(params) ||
51
+ typeof params.providerId !== "string" ||
52
+ !params.providerId)
53
+ throw new Error("OAuth providerId must be a non-empty string");
54
+ const key = params.providerId;
55
+ const items = await read();
56
+ if (operation === "get")
57
+ return Object.hasOwn(items, key) ? items[key] : null;
58
+ if (operation === "remove")
59
+ delete items[key];
60
+ else {
61
+ const tokens = params.tokens;
62
+ if (!tokens ||
63
+ typeof tokens !== "object" ||
64
+ Array.isArray(tokens) ||
65
+ typeof tokens.accessToken !== "string")
66
+ throw new Error("Invalid OAuth token set");
67
+ for (const field of ["refreshToken", "idToken", "scope"])
68
+ if (tokens[field] !== undefined && typeof tokens[field] !== "string")
69
+ throw new Error("Invalid OAuth token set");
70
+ if (params.revocationEndpoint !== undefined &&
71
+ (typeof params.revocationEndpoint !== "string" || params.revocationEndpoint === ""))
72
+ throw new Error("Invalid OAuth revocation endpoint");
73
+ if (tokens.expiresIn !== undefined &&
74
+ (typeof tokens.expiresIn !== "number" || !Number.isFinite(tokens.expiresIn)))
75
+ throw new Error("Invalid OAuth token expiry");
76
+ const stored = {
77
+ accessToken: tokens.accessToken,
78
+ updatedAt: new Date().toISOString(),
79
+ };
80
+ for (const field of ["refreshToken", "idToken", "scope", "expiresIn"])
81
+ if (tokens[field] !== undefined)
82
+ stored[field] = tokens[field];
83
+ if (params.revocationEndpoint !== undefined)
84
+ stored.revocationEndpoint = params.revocationEndpoint;
85
+ Object.defineProperty(items, key, {
86
+ value: stored,
87
+ enumerable: true,
88
+ configurable: true,
89
+ writable: true,
90
+ });
91
+ }
92
+ await write(items);
93
+ });
94
+ return {
95
+ "oauth.tokens.get": handler("get"),
96
+ "oauth.tokens.set": handler("set"),
97
+ "oauth.tokens.remove": handler("remove"),
98
+ };
99
+ }
@@ -0,0 +1,15 @@
1
+ import { type StorageItems, type StorageOptions } from "./storage.js";
2
+ type Preferences = {
3
+ extension: StorageItems;
4
+ commands: Record<string, StorageItems>;
5
+ };
6
+ export declare function preferenceFile(dataDir: string, extensionName: string): string;
7
+ /** Plaintext preferences, isolated by extension and command, atomically saved as 0600. */
8
+ export declare function createPreferenceStore(options: StorageOptions): {
9
+ read: () => Promise<Preferences>;
10
+ values: (command: string) => Promise<{
11
+ [x: string]: import("./storage.js").StorageValue;
12
+ }>;
13
+ save: (extension: StorageItems, command: string, values: StorageItems) => Promise<void>;
14
+ };
15
+ export {};
@@ -0,0 +1,61 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join, basename } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { defaultDataDir, storageFile, isStorageValue, } from "./storage.js";
5
+ function items(value) {
6
+ return (!!value &&
7
+ typeof value === "object" &&
8
+ !Array.isArray(value) &&
9
+ Object.values(value).every(isStorageValue));
10
+ }
11
+ export function preferenceFile(dataDir, extensionName) {
12
+ return join(dataDir, "preferences", basename(storageFile(dataDir, extensionName)));
13
+ }
14
+ /** Plaintext preferences, isolated by extension and command, atomically saved as 0600. */
15
+ export function createPreferenceStore(options) {
16
+ const file = preferenceFile(options.dataDir ?? defaultDataDir(), options.extensionName);
17
+ const read = async () => {
18
+ try {
19
+ const value = JSON.parse(await readFile(file, "utf8"));
20
+ if (!value ||
21
+ !items(value.extension) ||
22
+ !value.commands ||
23
+ typeof value.commands !== "object" ||
24
+ Array.isArray(value.commands) ||
25
+ !Object.values(value.commands).every(items))
26
+ throw new Error("Invalid preferences file");
27
+ return value;
28
+ }
29
+ catch (error) {
30
+ if (error.code === "ENOENT")
31
+ return { extension: {}, commands: {} };
32
+ throw error;
33
+ }
34
+ };
35
+ return {
36
+ read,
37
+ values: async (command) => {
38
+ const stored = await read();
39
+ return { ...stored.extension, ...stored.commands[command] };
40
+ },
41
+ save: async (extension, command, values) => {
42
+ if (![...Object.values(extension), ...Object.values(values)].every(isStorageValue))
43
+ throw new Error("Invalid preference value");
44
+ const stored = await read();
45
+ stored.extension = { ...stored.extension, ...extension };
46
+ stored.commands = {
47
+ ...stored.commands,
48
+ [command]: { ...stored.commands[command], ...values },
49
+ };
50
+ await mkdir(dirname(file), { recursive: true });
51
+ const temporary = `${file}.${randomUUID()}.tmp`;
52
+ try {
53
+ await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
54
+ await rename(temporary, file);
55
+ }
56
+ finally {
57
+ await rm(temporary, { force: true });
58
+ }
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,15 @@
1
+ import { type ModelRequest, type ModelStreamPart, type RegisteredModel } from "@blastlauncher/protocol";
2
+ import { type StartOptions } from "./session.js";
3
+ export declare function startModelProvider(options: Omit<StartOptions, "broker" | "providerResponse"> & {
4
+ broker?: StartOptions["broker"];
5
+ }): Promise<{
6
+ shutdown: (options?: {
7
+ timeoutMs?: number;
8
+ }) => Promise<import("./session.js").Exit>;
9
+ exited: Promise<import("./session.js").Exit>;
10
+ logs: readonly import("@blastlauncher/protocol").LogMessage[];
11
+ getModels(): Promise<RegisteredModel[]>;
12
+ stream(model: RegisteredModel, request: ModelRequest, { signal }?: {
13
+ signal?: AbortSignal;
14
+ }): AsyncGenerator<ModelStreamPart>;
15
+ }>;
@@ -0,0 +1,102 @@
1
+ import { encodeModelRequest, } from "@blastlauncher/protocol";
2
+ import { createBroker } from "./broker.js";
3
+ import { startCommand } from "./session.js";
4
+ export async function startModelProvider(options) {
5
+ let nextId = 0;
6
+ let closed = false;
7
+ const pending = new Map();
8
+ const session = await startCommand({
9
+ ...options,
10
+ mode: "model-provider",
11
+ broker: options.broker ?? createBroker({}),
12
+ providerResponse(message) {
13
+ pending.get(message.id)?.(message);
14
+ },
15
+ });
16
+ void session.exited.then((exit) => {
17
+ closed = true;
18
+ for (const receive of pending.values())
19
+ receive(exit.error ?? new Error("Model provider exited"));
20
+ pending.clear();
21
+ });
22
+ return {
23
+ shutdown: session.shutdown,
24
+ exited: session.exited,
25
+ logs: session.logs,
26
+ getModels() {
27
+ if (closed)
28
+ return Promise.reject(new Error("Model provider exited"));
29
+ const id = String(++nextId);
30
+ return new Promise((resolve, reject) => {
31
+ pending.set(id, (message) => {
32
+ pending.delete(id);
33
+ if (message instanceof Error)
34
+ reject(message);
35
+ else if (message.type === "provider.models")
36
+ resolve(message.models);
37
+ else if (message.type === "provider.error")
38
+ reject(Object.assign(new Error(message.error.message), { code: message.error.code }));
39
+ else
40
+ reject(new Error("Unexpected model provider response"));
41
+ });
42
+ session.sendProvider({ type: "provider.getModels", id });
43
+ });
44
+ },
45
+ async *stream(model, request, { signal } = {}) {
46
+ if (closed)
47
+ throw new Error("Model provider exited");
48
+ const id = String(++nextId);
49
+ const queue = [];
50
+ let done = false;
51
+ let failure;
52
+ let wake;
53
+ const abort = () => {
54
+ failure = new DOMException("The operation was aborted", "AbortError");
55
+ session.sendProvider({ type: "provider.abort", id });
56
+ wake?.();
57
+ };
58
+ if (signal?.aborted)
59
+ throw new DOMException("The operation was aborted", "AbortError");
60
+ pending.set(id, (message) => {
61
+ if (message instanceof Error)
62
+ failure = message;
63
+ else if (message.type === "provider.error")
64
+ failure = Object.assign(new Error(message.error.message), { code: message.error.code });
65
+ else if (message.type === "provider.part")
66
+ queue.push(message.part);
67
+ else if (message.type === "provider.end")
68
+ done = true;
69
+ else
70
+ failure = new Error("Unexpected model provider response");
71
+ wake?.();
72
+ });
73
+ signal?.addEventListener("abort", abort, { once: true });
74
+ try {
75
+ session.sendProvider({
76
+ type: "provider.stream",
77
+ id,
78
+ model,
79
+ request: encodeModelRequest(request),
80
+ });
81
+ while (true) {
82
+ if (failure)
83
+ throw failure;
84
+ if (queue.length)
85
+ yield queue.shift();
86
+ else if (done)
87
+ return;
88
+ else
89
+ await new Promise((resolve) => {
90
+ wake = resolve;
91
+ });
92
+ }
93
+ }
94
+ finally {
95
+ pending.delete(id);
96
+ signal?.removeEventListener("abort", abort);
97
+ if (!done)
98
+ session.sendProvider({ type: "provider.abort", id });
99
+ }
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,44 @@
1
+ import { type ProviderRequest, type ProviderResponse, type InitEnvironment, type JsonValue, type LogMessage, type SceneMessage } from "@blastlauncher/protocol";
2
+ import type { Broker } from "./broker.js";
3
+ import type { BundledCommand } from "./bundle.js";
4
+ export type StartOptions = Omit<BundledCommand, "bundleMs"> & {
5
+ broker: Broker;
6
+ providerResponse?: (message: ProviderResponse) => void;
7
+ dataDir?: string;
8
+ preferences?: Record<string, JsonValue>;
9
+ environment?: InitEnvironment;
10
+ availableAPIs?: InitEnvironment["availableAPIs"];
11
+ /** Manifest argument values for this launch, keyed by argument name. */
12
+ argumentValues?: Record<string, string>;
13
+ /** The `launchCommand` context, when another command started this one. */
14
+ launchContext?: Record<string, JsonValue>;
15
+ fallbackText?: string;
16
+ nodePath?: string;
17
+ handshakeTimeoutMs?: number;
18
+ /** Child environment; defaults to {@link runtimeEnvironment} (an allowlist, not `process.env`). */
19
+ env?: Record<string, string>;
20
+ /** Oldest log entries are dropped beyond this many. */
21
+ maxLogs?: number;
22
+ };
23
+ export declare function runtimeEnvironment(source?: NodeJS.ProcessEnv): Record<string, string>;
24
+ export type Exit = {
25
+ code: number | null;
26
+ signal: NodeJS.Signals | null;
27
+ error?: Error;
28
+ };
29
+ export type Session = {
30
+ readonly latestScene: SceneMessage | undefined;
31
+ readonly logs: readonly LogMessage[];
32
+ /** Subscribe to log lines as they arrive (no replay); returns unsubscribe. */
33
+ onLog(callback: (log: LogMessage) => void): () => void;
34
+ /** Subscribe to snapshots; immediately replays the latest one if present. */
35
+ scenes(callback: (scene: SceneMessage) => void): () => void;
36
+ sendProvider(message: ProviderRequest): void;
37
+ sendEvent(node: string, handler: string, args: JsonValue[]): void;
38
+ shutdown(options?: {
39
+ timeoutMs?: number;
40
+ }): Promise<Exit>;
41
+ exited: Promise<Exit>;
42
+ };
43
+ /** Resolves after the version handshake. Startup failures reject only after child cleanup. */
44
+ export declare function startCommand(options: StartOptions): Promise<Session>;