@melaya/runner 1.0.17 → 1.0.18

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.
Files changed (2) hide show
  1. package/dist/connection.js +96 -0
  2. package/package.json +1 -1
@@ -197,6 +197,102 @@ export async function connect(opts) {
197
197
  console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
198
198
  }
199
199
  });
200
+ // ── LLM stream relay ───────────────────────────────────────────────
201
+ //
202
+ // The cloud server picked the user's local Ollama / LM Studio model
203
+ // for an AI feature (Build-with-AI architect, etc.) — but the LLM
204
+ // server only exists on this machine, so we proxy the chat-completion
205
+ // here: fetch the local URL, stream chunks back over the existing
206
+ // socket. The server-side `streamLLMViaRunner()` consumes them.
207
+ //
208
+ // Protocol:
209
+ // server → runner: runner:llm-stream-request { reqId, url, headers, body }
210
+ // runner → server: runner:llm-stream-chunk { reqId, chunk: string }
211
+ // runner → server: runner:llm-stream-end { reqId, status }
212
+ // runner → server: runner:llm-stream-error { reqId, error }
213
+ // server → runner: runner:llm-stream-cancel { reqId }
214
+ const _activeLLMStreams = new Map();
215
+ socket.on("runner:llm-stream-request", async (req) => {
216
+ const reqId = req?.reqId;
217
+ if (!reqId || typeof req.url !== "string")
218
+ return;
219
+ // Defense-in-depth: only allow loopback URLs. The server already
220
+ // generates these as `http://localhost:1234` / `http://localhost:11434`,
221
+ // but enforcing it here means a compromised server connection can't
222
+ // pivot the runner into making outbound requests on its behalf.
223
+ let parsed;
224
+ try {
225
+ parsed = new URL(req.url);
226
+ }
227
+ catch {
228
+ socket.emit("runner:llm-stream-error", { reqId, error: "invalid_url" });
229
+ return;
230
+ }
231
+ if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "::1") {
232
+ socket.emit("runner:llm-stream-error", { reqId, error: "non_loopback_url_rejected" });
233
+ return;
234
+ }
235
+ if (opts.verbose)
236
+ console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} → ${req.url}`));
237
+ const ac = new AbortController();
238
+ _activeLLMStreams.set(reqId, ac);
239
+ try {
240
+ const upstream = await fetch(req.url, {
241
+ method: "POST",
242
+ headers: req.headers || { "content-type": "application/json" },
243
+ body: req.body || "",
244
+ signal: ac.signal,
245
+ });
246
+ if (!upstream.body) {
247
+ const text = await upstream.text().catch(() => "");
248
+ socket.emit("runner:llm-stream-error", {
249
+ reqId,
250
+ error: `upstream_no_body status=${upstream.status} ${text.slice(0, 200)}`,
251
+ });
252
+ return;
253
+ }
254
+ // Pipe chunks back as decoded text. The cloud server's parser is
255
+ // text-oriented (SSE `data:` lines) so we decode here and ship
256
+ // strings rather than base64 — keeps the WS payload small + lets
257
+ // the server keep its existing line-buffer code path.
258
+ const reader = upstream.body.getReader();
259
+ const decoder = new TextDecoder();
260
+ while (true) {
261
+ const { value, done } = await reader.read();
262
+ if (done)
263
+ break;
264
+ const chunk = decoder.decode(value, { stream: true });
265
+ if (chunk.length > 0) {
266
+ socket.emit("runner:llm-stream-chunk", { reqId, chunk });
267
+ }
268
+ }
269
+ // Flush any tail bytes.
270
+ const tail = decoder.decode();
271
+ if (tail.length > 0)
272
+ socket.emit("runner:llm-stream-chunk", { reqId, chunk: tail });
273
+ socket.emit("runner:llm-stream-end", { reqId, status: upstream.status });
274
+ if (opts.verbose)
275
+ console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} ◄ end ${upstream.status}`));
276
+ }
277
+ catch (e) {
278
+ if (e?.name === "AbortError") {
279
+ socket.emit("runner:llm-stream-error", { reqId, error: "aborted" });
280
+ }
281
+ else {
282
+ socket.emit("runner:llm-stream-error", { reqId, error: String(e?.message || e) });
283
+ }
284
+ }
285
+ finally {
286
+ _activeLLMStreams.delete(reqId);
287
+ }
288
+ });
289
+ socket.on("runner:llm-stream-cancel", (req) => {
290
+ const ac = _activeLLMStreams.get(req?.reqId);
291
+ if (ac) {
292
+ ac.abort();
293
+ _activeLLMStreams.delete(req.reqId);
294
+ }
295
+ });
200
296
  // ── Graceful shutdown ──────────────────────────────────────────────
201
297
  const cleanup = () => {
202
298
  console.log(chalk.gray("\n Shutting down..."));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,