relmio 0.7.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ checks the registry separately after publication.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.8.1] - 2026-08-26
11
+
12
+ ### Changed
13
+
14
+ - Adopt the Gateway Android logo across the GitHub and npm READMEs, hosted
15
+ site, and local wizard, and stop publishing the retired Harbor Gate mark.
16
+ - Refresh the Open Graph and social-preview card with the Gateway Android while
17
+ preserving the Relmio relay message and visual flow.
18
+ - Add a prominent Legal warning against bypassing rate limits, restrictions, or
19
+ safeguards.
20
+
21
+ ## [0.8.0] - 2026-08-26
22
+
23
+ ### Added
24
+
25
+ - Let trusted local backends and development servers receive Chat Adapter turns
26
+ as opt-in Server-Sent Events, with progress and text deltas followed by one
27
+ explicit terminal outcome so a completed response is distinguishable from a
28
+ redacted failure.
29
+ - Let the setup-token-protected local wizard tester show that incremental
30
+ response flow after a short-lived encrypted credential handoff, without a
31
+ direct browser-to-adapter request.
32
+ - Refresh the hosted chat experience and local installer presentation, and
33
+ adopt the Harbor Gate abstract mark across Relmio surfaces.
34
+
35
+ ### Changed
36
+
37
+ - Document the streaming contract, local tester behavior, and its limits in the
38
+ canonical endpoint/reference guides and generated hosted documentation.
39
+
40
+ ### Security
41
+
42
+ - Keep the Chat Adapter experimental, loopback-only, and limited to trusted
43
+ local backends or development servers; it rejects browser origins and is not
44
+ an OpenAI `/v1` endpoint or a substitute for an OpenAI Platform API key.
45
+
10
46
  ## [0.7.0] - 2026-08-16
11
47
 
12
48
  ### Added
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://cdn.jsdelivr.net/npm/relmio@latest/docs/images/brand/relmio-logo.png" alt="Relmio gateway android logo" width="180">
3
+ </p>
4
+
1
5
  # Relmio
2
6
 
3
7
  This package starts Relmio's local setup wizard for a private n8n sidecar or a
@@ -18,13 +22,18 @@ final confirmation before it writes files or deploys a VPS sidecar.
18
22
  | --- | --- | --- |
19
23
  | Local OpenAI gateway | OpenAI-compatible `/v1` | Your OpenAI Platform API key |
20
24
  | Codex App Server | Experimental JSON-RPC/WebSocket | ChatGPT sign-in and a high-trust local capability |
21
- | Codex Chat Adapter | Experimental `POST /chat` | ChatGPT sign-in and a bearer for trusted local backends |
25
+ | Codex Chat Adapter | Experimental `POST /chat` (JSON or opt-in SSE) | ChatGPT sign-in and a bearer for trusted local backends |
22
26
  | n8n sidecar | Private Docker-network `/v1` bridge | A local ChatGPT sign-in file, never a host port |
23
27
 
24
28
  ChatGPT sign-in does not become an OpenAI Platform API key. Codex transports
25
29
  are not generic `/v1` services and should never be exposed on a LAN or public
26
30
  network.
27
31
 
32
+ The experimental Chat Adapter's SSE stream succeeds only after its
33
+ `terminal: completed` event. Its local tester stays behind the setup-token-
34
+ protected wizard and uses a short-lived encrypted credential handoff, never a
35
+ direct browser-to-adapter request.
36
+
28
37
  ## ChatGPT sign-in lifetime
29
38
 
30
39
  ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
package/docs/brand.md CHANGED
@@ -8,17 +8,19 @@ private OpenAI-compatible OAuth sidecar for self-hosted n8n, while the product
8
8
  direction can grow to support other OpenAI-compatible clients and SDK-based
9
9
  applications.
10
10
 
11
- ## Mark
11
+ ## Logo and icon
12
12
 
13
- ![Relmio mark](images/brand/relmio-mark.svg)
13
+ ![Relmio gateway android logo](images/brand/relmio-logo.png)
14
14
 
15
- Two independent lanes converge around a right-facing negative-space arrow. The
16
- shape represents an authenticated upstream connection being relayed through a
17
- single compatible interface.
15
+ The **Gateway Android** turns Relmio's model relay into a friendly, minimal
16
+ guide standing inside a protected passage. Its teal face represents the live
17
+ AI connection, while the ivory arch depicts the local boundary through which
18
+ requests are deliberately relayed. The two quiet eyes keep it recognizable at
19
+ favicon size without borrowing another provider's branding.
18
20
 
19
- The mark was developed from the checked-in generated concept source at
20
- [`images/brand/relmio-concept-source.png`](images/brand/relmio-concept-source.png)
21
- and redrawn as compact SVG geometry for reliable rendering at small sizes.
21
+ The checked-in 512-pixel PNG is the canonical logo and icon asset. It was
22
+ derived from the selected generated source, then resized once for consistent
23
+ rendering across the README, hosted app, and local wizard.
22
24
 
23
25
  ## Colors
24
26
 
@@ -28,11 +30,11 @@ and redrawn as compact SVG geometry for reliable rendering at small sizes.
28
30
  | Midnight ink | `#12211f` | Secondary lane, text, high-contrast surfaces |
29
31
  | Signal tint | `#e7f3f0` | Quiet selected and informational surfaces |
30
32
  | Canvas | `#f4f2ed` | Warm application background |
33
+ | Gateway moss | `#8fa58b` | Logo field and calm supporting brand surfaces |
31
34
 
32
- Use the two-color mark on light backgrounds. On a dark surface, place the
33
- unchanged mark on a white or canvas-colored tile with enough padding. Do not
34
- rotate, stretch, outline, add gradients, or recreate the mark with provider
35
- brand colors.
35
+ Keep the logo square and preserve its built-in moss background on both light
36
+ and dark surfaces. Do not crop, rotate, stretch, outline, recolor, or recreate
37
+ the logo with provider brand colors.
36
38
 
37
39
  ## Compatibility names
38
40
 
@@ -236,7 +236,7 @@ Protocol: Relmio Codex Chat HTTP
236
236
  ```
237
237
 
238
238
  After completing the same official Codex device-code sign-in, a local backend
239
- can start a conversation with. Read the bearer rather than placing it in a
239
+ can start a conversation. Read the bearer rather than placing it in a
240
240
  shell command:
241
241
 
242
242
  ```bash
@@ -261,6 +261,24 @@ text:
261
261
  }
262
262
  ```
263
263
 
264
+ To verify incremental delivery, request Relmio's versioned event stream. The
265
+ stream emits `start`, `progress`, zero or more `delta` events, and exactly one
266
+ `terminal` event. A completed terminal includes the `conversationId`; a failed
267
+ terminal is preceded by a redacted `error` event:
268
+
269
+ ```bash
270
+ read -r -s RELMIO_CODEX_CHAT_KEY
271
+ printf '\n'
272
+ printf 'Authorization: Bearer %s\n' "$RELMIO_CODEX_CHAT_KEY" |
273
+ curl --no-buffer --fail-with-body --silent --show-error \
274
+ --request POST http://127.0.0.1:14501/chat \
275
+ --header @- \
276
+ --header "Accept: text/event-stream" \
277
+ --header "Content-Type: application/json" \
278
+ --data '{"input":"What is a robot? Answer in two short sentences."}'
279
+ unset RELMIO_CODEX_CHAT_KEY
280
+ ```
281
+
264
282
  Send that `conversationId` with the next `input` to continue the same
265
283
  conversation. The adapter initializes the official App Server, starts or
266
284
  resumes the thread, runs a read-only conversational turn, and returns the
@@ -301,7 +319,9 @@ This reduces accidental credential transit and storage exposure. It is not
301
319
  encryption at rest or end-to-end encryption, and it cannot protect against a
302
320
  compromised browser, extension, or local machine. The tester rejects redirects,
303
321
  non-loopback URLs, malformed or oversized data, concurrent key use, and
304
- adapter failures with redacted messages.
322
+ adapter failures with redacted messages. Assistant text appears incrementally
323
+ while the adapter is working; the tester reports success only after the
324
+ completed terminal event arrives.
305
325
 
306
326
  ## Network and container boundary
307
327
 
package/docs/reference.md CHANGED
@@ -26,6 +26,23 @@ printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
26
26
  --data '{"input":"Reply with exactly: adapter works"}'
27
27
  ```
28
28
 
29
+ Stream a conversation and inspect its explicit terminal state:
30
+
31
+ ```bash
32
+ printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
33
+ curl --no-buffer --fail-with-body --silent --show-error \
34
+ --request POST "$RELMIO_CHAT_BASE_URL/chat" \
35
+ --header @- \
36
+ --header "Accept: text/event-stream" \
37
+ --header "Content-Type: application/json" \
38
+ --data '{"input":"What is love? Answer conversationally."}'
39
+ ```
40
+
41
+ The event order is `start`, `progress`, zero or more `delta` events, then one
42
+ `terminal`. Only `terminal: completed` is success. A failed terminal is
43
+ preceded by a redacted `error` event and must not be treated as a partial
44
+ answer.
45
+
29
46
  Copy the returned `conversationId`, then send a continuation:
30
47
 
31
48
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relmio",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Install private local OpenAI API and Codex endpoints with explicit provider credential boundaries, plus the existing isolated n8n sidecar.",
5
5
  "keywords": [
6
6
  "relmio",
@@ -1,4 +1,4 @@
1
- import { createHash, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
4
  import { pathToFileURL } from "node:url";
@@ -12,6 +12,7 @@ const MAX_PROTOCOL_STDOUT_BYTES = 256 * 1024;
12
12
  const MAX_PROTOCOL_STDERR_BYTES = 64 * 1024;
13
13
  const TURN_TIMEOUT_MS = 120_000;
14
14
  const TERMINATION_GRACE_MS = 2_000;
15
+ const KEEPALIVE_INTERVAL_MS = 15_000;
15
16
  const CONVERSATIONAL_INSTRUCTION =
16
17
  "Provide a conversational answer only. Do not inspect or edit files, run commands, call tools, or access external resources.";
17
18
 
@@ -65,6 +66,70 @@ function sendError(response, status, code) {
65
66
  sendJson(response, status, { error: { code } });
66
67
  }
67
68
 
69
+ function acceptsEventStream(request) {
70
+ if (headerOccurrences(request, "accept") > 1) {
71
+ return false;
72
+ }
73
+ const value = request.headers.accept;
74
+ return (
75
+ typeof value === "string" &&
76
+ value
77
+ .split(",")
78
+ .some((entry) => entry.trim().split(";", 1)[0] === "text/event-stream")
79
+ );
80
+ }
81
+
82
+ function startEventStream(response, keepaliveIntervalMs) {
83
+ let ended = false;
84
+ response.writeHead(200, {
85
+ "Cache-Control": "no-store",
86
+ Connection: "keep-alive",
87
+ "Content-Encoding": "none",
88
+ "Content-Type": "text/event-stream; charset=utf-8",
89
+ "X-Accel-Buffering": "no",
90
+ "X-Content-Type-Options": "nosniff",
91
+ "X-Relmio-Stream": "v1",
92
+ });
93
+ const send = (event, data) => {
94
+ if (ended || response.writableEnded || response.destroyed) {
95
+ return;
96
+ }
97
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
98
+ };
99
+ const keepalive = setInterval(() => {
100
+ if (!ended && !response.writableEnded && !response.destroyed) {
101
+ response.write(": keepalive\n\n");
102
+ }
103
+ }, keepaliveIntervalMs);
104
+ keepalive.unref?.();
105
+ send("start", { requestId: randomUUID() });
106
+ return {
107
+ send,
108
+ complete(result) {
109
+ if (ended) return;
110
+ send("terminal", {
111
+ outcome: "completed",
112
+ conversationId: result.conversationId,
113
+ });
114
+ ended = true;
115
+ clearInterval(keepalive);
116
+ response.end();
117
+ },
118
+ fail(code = "upstream_failed", retryable = true) {
119
+ if (ended) return;
120
+ send("error", { code, retryable });
121
+ send("terminal", { outcome: "failed" });
122
+ ended = true;
123
+ clearInterval(keepalive);
124
+ response.end();
125
+ },
126
+ dispose() {
127
+ ended = true;
128
+ clearInterval(keepalive);
129
+ },
130
+ };
131
+ }
132
+
68
133
  function hasValidBearer(request, verifier) {
69
134
  if (headerOccurrences(request, "authorization") !== 1) {
70
135
  return false;
@@ -196,6 +261,8 @@ function createAppServerOperation({
196
261
  input,
197
262
  conversationId,
198
263
  packageVersion,
264
+ onEvent,
265
+ onFailure,
199
266
  signal,
200
267
  spawnProcess,
201
268
  terminationGraceMs,
@@ -241,6 +308,7 @@ function createAppServerOperation({
241
308
  let finalOutput = null;
242
309
  let latestDeltaItemId = null;
243
310
  let deltaBytes = 0;
311
+ let emittedDeltaBytes = 0;
244
312
  const deltaOutputs = new Map();
245
313
  let phase = "initializing";
246
314
  let outcome = null;
@@ -298,6 +366,9 @@ function createAppServerOperation({
298
366
  return;
299
367
  }
300
368
  outcome = { error, result };
369
+ if (error) {
370
+ onFailure?.(error.message === "timeout" ? "timeout" : "upstream_failed");
371
+ }
301
372
  signal?.removeEventListener?.("abort", abortOperation);
302
373
  clearTimeout(timeout);
303
374
  timeout = undefined;
@@ -316,6 +387,7 @@ function createAppServerOperation({
316
387
  };
317
388
  const startThread = () => {
318
389
  phase = "thread";
390
+ onEvent?.("progress", { phase: "starting_thread" });
319
391
  write({
320
392
  id: 1,
321
393
  method: conversationId ? "thread/resume" : "thread/start",
@@ -337,6 +409,7 @@ function createAppServerOperation({
337
409
  };
338
410
  const startTurn = () => {
339
411
  phase = "turn";
412
+ onEvent?.("progress", { phase: "starting_turn" });
340
413
  write({
341
414
  id: 2,
342
415
  method: "turn/start",
@@ -418,11 +491,13 @@ function createAppServerOperation({
418
491
  return;
419
492
  }
420
493
  deltaBytes += partBytes;
494
+ emittedDeltaBytes += partBytes;
421
495
  latestDeltaItemId = params.itemId;
422
496
  deltaOutputs.set(
423
497
  params.itemId,
424
498
  `${deltaOutputs.get(params.itemId) ?? ""}${params.delta}`,
425
499
  );
500
+ onEvent?.("delta", { text: params.delta });
426
501
  return;
427
502
  }
428
503
  if (message.method === "item/completed") {
@@ -468,6 +543,9 @@ function createAppServerOperation({
468
543
  failProtocol();
469
544
  return;
470
545
  }
546
+ if (emittedDeltaBytes === 0) {
547
+ onEvent?.("delta", { text: output });
548
+ }
471
549
  settle(null, { conversationId: threadId, output });
472
550
  return;
473
551
  }
@@ -552,6 +630,7 @@ function createAppServerOperation({
552
630
  },
553
631
  },
554
632
  });
633
+ onEvent?.("progress", { phase: "initializing" });
555
634
  });
556
635
  }
557
636
 
@@ -603,6 +682,7 @@ export async function startCodexChatGateway({
603
682
  spawnProcess = spawn,
604
683
  terminationGraceMs = TERMINATION_GRACE_MS,
605
684
  turnTimeoutMs = TURN_TIMEOUT_MS,
685
+ keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
606
686
  } = {}) {
607
687
  if (!isLoopbackListenHost(host)) {
608
688
  throw new TypeError("Codex Chat must listen on a literal loopback-safe host.");
@@ -627,6 +707,13 @@ export async function startCodexChatGateway({
627
707
  ) {
628
708
  throw new TypeError("The Codex Chat turn timeout is invalid.");
629
709
  }
710
+ if (
711
+ !Number.isSafeInteger(keepaliveIntervalMs) ||
712
+ keepaliveIntervalMs < 1 ||
713
+ keepaliveIntervalMs > 60_000
714
+ ) {
715
+ throw new TypeError("The Codex Chat keepalive interval is invalid.");
716
+ }
630
717
  const verifier = validateTokenVerifier(tokenVerifier);
631
718
  const safePackageVersion = validatePackageVersion(packageVersion);
632
719
  let activeOperation = false;
@@ -673,6 +760,7 @@ export async function startCodexChatGateway({
673
760
  activeOperation = true;
674
761
  const controller = new AbortController();
675
762
  let disconnected = false;
763
+ let eventStream;
676
764
  const onDisconnect = () => {
677
765
  if (request.aborted || !response.writableEnded) {
678
766
  disconnected = true;
@@ -686,9 +774,16 @@ export async function startCodexChatGateway({
686
774
  if (disconnected) {
687
775
  throw new Error("unavailable");
688
776
  }
777
+ if (acceptsEventStream(request)) {
778
+ eventStream = startEventStream(response, keepaliveIntervalMs);
779
+ }
689
780
  return createAppServerOperation({
690
781
  ...chat,
691
782
  packageVersion: safePackageVersion,
783
+ onEvent: eventStream?.send,
784
+ onFailure: eventStream
785
+ ? (code) => eventStream.fail(code)
786
+ : undefined,
692
787
  signal: controller.signal,
693
788
  spawnProcess,
694
789
  terminationGraceMs,
@@ -697,13 +792,18 @@ export async function startCodexChatGateway({
697
792
  })
698
793
  .then((result) => {
699
794
  if (!disconnected && !response.writableEnded) {
700
- sendJson(response, 200, result);
795
+ if (eventStream) eventStream.complete(result);
796
+ else sendJson(response, 200, result);
701
797
  }
702
798
  })
703
799
  .catch((error) => {
704
800
  if (disconnected || response.writableEnded) {
705
801
  return;
706
802
  }
803
+ if (eventStream) {
804
+ eventStream.fail();
805
+ return;
806
+ }
707
807
  const status = error?.status;
708
808
  const code = error?.code;
709
809
  if (status === 413 || status === 499 || code === "invalid_json" || code === "invalid_request") {
@@ -713,6 +813,7 @@ export async function startCodexChatGateway({
713
813
  sendError(response, 503, "unavailable");
714
814
  })
715
815
  .finally(() => {
816
+ eventStream?.dispose();
716
817
  activeOperation = false;
717
818
  });
718
819
  });
@@ -15,7 +15,7 @@ const MAX_ENDPOINT_LENGTH = 64;
15
15
  const MAX_CIPHERTEXT_LENGTH = 4_096;
16
16
  const MAX_INPUT_LENGTH = 8_192;
17
17
  const MAX_CONVERSATION_ID_LENGTH = 160;
18
- const MAX_RESPONSE_BYTES = 16 * 1_024;
18
+ const MAX_RESPONSE_BYTES = 512 * 1_024;
19
19
  const MAX_OUTPUT_LENGTH = 12 * 1_024;
20
20
 
21
21
  function requestError(message, statusCode = 400) {
@@ -173,6 +173,130 @@ function parseAdapterResponse(text) {
173
173
  };
174
174
  }
175
175
 
176
+ function parseEventBlock(block) {
177
+ const dataLines = [];
178
+ let event;
179
+ for (const line of block.split(/\r?\n/u)) {
180
+ if (line.startsWith("event:")) {
181
+ event = line.slice(6).trim();
182
+ } else if (line.startsWith("data:")) {
183
+ dataLines.push(line.slice(5).trimStart());
184
+ }
185
+ }
186
+ if (!event || dataLines.length === 0) {
187
+ throw adapterError();
188
+ }
189
+ let data;
190
+ try {
191
+ data = JSON.parse(dataLines.join("\n"));
192
+ } catch {
193
+ throw adapterError();
194
+ }
195
+ if (!isPlainObject(data)) {
196
+ throw adapterError();
197
+ }
198
+ return { event, data };
199
+ }
200
+
201
+ async function consumeAdapterStream(response, onEvent) {
202
+ if (
203
+ !/^text\/event-stream(?:\s*;|$)/iu.test(
204
+ response.headers?.get?.("content-type") ?? "",
205
+ ) ||
206
+ response.headers?.get?.("x-relmio-stream") !== "v1"
207
+ ) {
208
+ throw adapterError();
209
+ }
210
+ const reader = response.body?.getReader?.();
211
+ if (!reader) {
212
+ throw adapterError();
213
+ }
214
+
215
+ const decoder = new TextDecoder();
216
+ let buffer = "";
217
+ let bytes = 0;
218
+ let conversationId;
219
+ let failed = false;
220
+ let output = "";
221
+ let terminal = false;
222
+ const processBlock = (block) => {
223
+ if (!block.trim() || block.trimStart().startsWith(":")) {
224
+ return;
225
+ }
226
+ const { event, data } = parseEventBlock(block);
227
+ if (event === "start") {
228
+ return;
229
+ }
230
+ if (event === "progress") {
231
+ onEvent("progress", { phase: "working" });
232
+ return;
233
+ }
234
+ if (event === "delta") {
235
+ if (
236
+ typeof data.text !== "string" ||
237
+ data.text.length === 0 ||
238
+ data.text.includes("\0") ||
239
+ output.length + data.text.length > MAX_OUTPUT_LENGTH
240
+ ) {
241
+ throw adapterError();
242
+ }
243
+ output += data.text;
244
+ onEvent("delta", { text: data.text });
245
+ return;
246
+ }
247
+ if (event === "error") {
248
+ failed = true;
249
+ return;
250
+ }
251
+ if (event === "terminal") {
252
+ if (terminal || !["completed", "failed"].includes(data.outcome)) {
253
+ throw adapterError();
254
+ }
255
+ terminal = true;
256
+ failed ||= data.outcome !== "completed";
257
+ if (!failed) {
258
+ if (!isBoundedText(data.conversationId, MAX_CONVERSATION_ID_LENGTH)) {
259
+ throw adapterError();
260
+ }
261
+ conversationId = data.conversationId;
262
+ }
263
+ return;
264
+ }
265
+ throw adapterError();
266
+ };
267
+
268
+ try {
269
+ for (;;) {
270
+ const { done, value } = await reader.read();
271
+ if (done) break;
272
+ bytes += value.byteLength;
273
+ if (bytes > MAX_RESPONSE_BYTES) {
274
+ throw adapterError();
275
+ }
276
+ buffer += decoder.decode(value, { stream: true });
277
+ const blocks = buffer.split(/\r?\n\r?\n/u);
278
+ buffer = blocks.pop() ?? "";
279
+ for (const block of blocks) processBlock(block);
280
+ }
281
+ buffer += decoder.decode();
282
+ if (buffer.trim()) processBlock(buffer);
283
+ } catch (error) {
284
+ try {
285
+ await reader.cancel();
286
+ } catch {
287
+ // The adapter stream may already be closed after a malformed terminal.
288
+ }
289
+ throw error;
290
+ } finally {
291
+ reader.releaseLock?.();
292
+ }
293
+
294
+ if (failed || !terminal || !conversationId || output.length === 0) {
295
+ throw adapterError();
296
+ }
297
+ return { conversationId, output };
298
+ }
299
+
176
300
  function isTimeout(error) {
177
301
  return error?.name === "AbortError" || error?.name === "TimeoutError";
178
302
  }
@@ -265,8 +389,12 @@ export function createLocalChatTestService({
265
389
  }
266
390
  },
267
391
 
268
- async message(untrustedRequest) {
392
+ async message(untrustedRequest, options = {}) {
269
393
  const request = validateMessageRequest(untrustedRequest);
394
+ const onEvent =
395
+ typeof options.onEvent === "function" ? options.onEvent : null;
396
+ const externalSignal =
397
+ options.signal instanceof AbortSignal ? options.signal : null;
270
398
  const session = getLiveSession(request.keyId);
271
399
  if (session.inFlight) {
272
400
  throw requestError("Wait for the current test message to finish.", 409);
@@ -275,6 +403,7 @@ export function createLocalChatTestService({
275
403
  let encryptedCredential;
276
404
  let decryptedCredential;
277
405
  let authorization;
406
+ let abortFromCaller;
278
407
  let timeout;
279
408
  try {
280
409
  encryptedCredential = decodeCiphertext(request.encryptedCredential);
@@ -302,6 +431,14 @@ export function createLocalChatTestService({
302
431
  let response;
303
432
  try {
304
433
  session.abortController = new AbortController();
434
+ abortFromCaller = () => session.abortController?.abort();
435
+ if (externalSignal?.aborted) {
436
+ abortFromCaller();
437
+ } else {
438
+ externalSignal?.addEventListener("abort", abortFromCaller, {
439
+ once: true,
440
+ });
441
+ }
305
442
  timeout = setTimeout(
306
443
  () => session.abortController?.abort(),
307
444
  requestTimeoutMs,
@@ -309,7 +446,7 @@ export function createLocalChatTestService({
309
446
  response = await fetchImpl(`${request.endpointBaseUrl}/chat`, {
310
447
  method: "POST",
311
448
  headers: {
312
- Accept: "application/json",
449
+ Accept: onEvent ? "text/event-stream" : "application/json",
313
450
  Authorization: authorization,
314
451
  "Content-Type": "application/json",
315
452
  },
@@ -328,9 +465,12 @@ export function createLocalChatTestService({
328
465
  if (!response?.ok || response.status < 200 || response.status >= 300) {
329
466
  throw adapterError();
330
467
  }
331
- return parseAdapterResponse(await readBoundedResponse(response));
468
+ return onEvent
469
+ ? await consumeAdapterStream(response, onEvent)
470
+ : parseAdapterResponse(await readBoundedResponse(response));
332
471
  } finally {
333
472
  clearTimeout(timeout);
473
+ externalSignal?.removeEventListener("abort", abortFromCaller);
334
474
  authorization = undefined;
335
475
  encryptedCredential?.fill(0);
336
476
  decryptedCredential?.fill(0);
package/src/ui/index.html CHANGED
@@ -8,10 +8,7 @@
8
8
  content="Relmio connects compatible AI clients to a private ChatGPT/Codex OAuth sidecar, starting with self-hosted n8n."
9
9
  />
10
10
  <title>Relmio | n8n Setup</title>
11
- <link
12
- rel="icon"
13
- href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='%23137c74' d='M7 8h19l31 24H41L26 20v10L7 17Z'/%3E%3Cpath fill='%2312211f' d='M7 47l19-13v10l15-12h16L26 56H7Z'/%3E%3C/svg%3E"
14
- />
11
+ <link rel="icon" href="/relmio-icon.png" type="image/png" />
15
12
  <link rel="stylesheet" href="/styles.css" />
16
13
  <script src="/theme.js" type="module"></script>
17
14
  <script src="/app.js" type="module"></script>
@@ -21,15 +18,13 @@
21
18
 
22
19
  <header class="site-header">
23
20
  <div class="brand">
24
- <svg
21
+ <img
25
22
  class="brand-mark"
26
- viewBox="0 0 64 64"
27
- aria-hidden="true"
28
- focusable="false"
29
- >
30
- <path fill="#137c74" d="M7 8h19l31 24H41L26 20v10L7 17Z" />
31
- <path fill="#12211f" d="M7 47l19-13v10l15-12h16L26 56H7Z" />
32
- </svg>
23
+ src="/relmio-icon.png"
24
+ alt=""
25
+ width="28"
26
+ height="28"
27
+ />
33
28
  <span>Relmio</span>
34
29
  </div>
35
30
  <div class="header-actions">
package/src/ui/local.css CHANGED
@@ -6,6 +6,29 @@
6
6
  scroll-behavior: smooth;
7
7
  }
8
8
 
9
+ .local-wizard .local-eyebrow {
10
+ margin: 0 0 0.4rem;
11
+ color: var(--accent-deep);
12
+ font: 800 0.6875rem/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
+ letter-spacing: 0.12em;
14
+ text-transform: uppercase;
15
+ }
16
+
17
+ .local-wizard .rail {
18
+ padding: 1rem;
19
+ border: 1px solid var(--border);
20
+ border-radius: var(--radius);
21
+ background: color-mix(in srgb, var(--surface) 78%, transparent);
22
+ }
23
+
24
+ .local-wizard .steps li {
25
+ min-height: 2.5rem;
26
+ }
27
+
28
+ .local-wizard .steps li[aria-current="step"] em {
29
+ color: var(--text);
30
+ }
31
+
9
32
  .local-wizard .status-row {
10
33
  margin-bottom: 1rem;
11
34
  }
@@ -369,10 +392,14 @@
369
392
  display: grid;
370
393
  gap: 1rem;
371
394
  margin-top: 1.25rem;
372
- padding: 1rem;
395
+ padding: clamp(1rem, 2vw, 1.5rem);
373
396
  border: 1px solid var(--accent-border);
374
397
  border-radius: var(--radius);
375
- background: var(--accent-soft);
398
+ background:
399
+ linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
400
+ linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
401
+ var(--accent-soft);
402
+ background-size: 28px 28px;
376
403
  }
377
404
 
378
405
  .chat-tester-heading {
@@ -395,6 +422,42 @@
395
422
  margin: 0;
396
423
  }
397
424
 
425
+ .chat-tester-route {
426
+ display: grid;
427
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
428
+ align-items: center;
429
+ gap: 0.625rem;
430
+ padding: 0.75rem;
431
+ border: 1px solid var(--accent-border);
432
+ border-radius: var(--radius-sm);
433
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
434
+ }
435
+
436
+ .chat-tester-route > span {
437
+ display: grid;
438
+ gap: 0.2rem;
439
+ min-width: 0;
440
+ }
441
+
442
+ .chat-tester-route strong,
443
+ .chat-tester-route small {
444
+ overflow-wrap: anywhere;
445
+ }
446
+
447
+ .chat-tester-route strong {
448
+ font-size: 0.8125rem;
449
+ }
450
+
451
+ .chat-tester-route small {
452
+ color: var(--text-muted);
453
+ font: 0.6875rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
454
+ }
455
+
456
+ .chat-tester-route i {
457
+ color: var(--accent-deep);
458
+ font-style: normal;
459
+ }
460
+
398
461
  .chat-tester-form fieldset {
399
462
  display: grid;
400
463
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -460,6 +523,16 @@
460
523
  background: var(--accent-soft);
461
524
  }
462
525
 
526
+ .chat-tester-turn-incomplete {
527
+ border-color: var(--warning-border);
528
+ border-style: dashed;
529
+ background: var(--warning-soft);
530
+ }
531
+
532
+ .chat-tester-turn-incomplete strong {
533
+ color: var(--warning);
534
+ }
535
+
463
536
  .chat-tester-turn strong {
464
537
  display: block;
465
538
  margin-bottom: 0.25rem;
@@ -497,6 +570,14 @@
497
570
  flex-direction: column;
498
571
  }
499
572
 
573
+ .chat-tester-route {
574
+ grid-template-columns: minmax(0, 1fr);
575
+ }
576
+
577
+ .chat-tester-route i {
578
+ transform: rotate(90deg);
579
+ }
580
+
500
581
  .review-details div {
501
582
  grid-template-columns: minmax(0, 1fr);
502
583
  gap: 0.2rem;
package/src/ui/local.html CHANGED
@@ -8,10 +8,7 @@
8
8
  content="Install a loopback-only Relmio endpoint for the OpenAI API or the official Codex App Server."
9
9
  />
10
10
  <title>Relmio | Local Endpoint Setup</title>
11
- <link
12
- rel="icon"
13
- href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='%23137c74' d='M7 8h19l31 24H41L26 20v10L7 17Z'/%3E%3Cpath fill='%2312211f' d='M7 47l19-13v10l15-12h16L26 56H7Z'/%3E%3C/svg%3E"
14
- />
11
+ <link rel="icon" href="/relmio-icon.png" type="image/png" />
15
12
  <link rel="stylesheet" href="/styles.css" />
16
13
  <link rel="stylesheet" href="/local.css" />
17
14
  <script src="/theme.js" type="module"></script>
@@ -22,15 +19,13 @@
22
19
 
23
20
  <header class="site-header">
24
21
  <div class="brand">
25
- <svg
22
+ <img
26
23
  class="brand-mark"
27
- viewBox="0 0 64 64"
28
- aria-hidden="true"
29
- focusable="false"
30
- >
31
- <path fill="#137c74" d="M7 8h19l31 24H41L26 20v10L7 17Z" />
32
- <path fill="#12211f" d="M7 47l19-13v10l15-12h16L26 56H7Z" />
33
- </svg>
24
+ src="/relmio-icon.png"
25
+ alt=""
26
+ width="28"
27
+ height="28"
28
+ />
34
29
  <span>Relmio</span>
35
30
  </div>
36
31
  <div class="header-actions">
@@ -90,9 +85,10 @@
90
85
  <main id="main-content" class="shell" tabindex="-1">
91
86
  <aside class="rail" aria-label="Local setup progress and safety">
92
87
  <section class="intro" aria-labelledby="page-title">
93
- <h1 id="page-title">Run Relmio on localhost</h1>
88
+ <p class="local-eyebrow">Local pre-flight</p>
89
+ <h1 id="page-title">One boundary at a time</h1>
94
90
  <p class="lede">
95
- Choose the official credential path that matches your client. All
91
+ Run Relmio on localhost. Choose the official credential path that matches your client. All
96
92
  three options run in hardened Docker containers and bind only to
97
93
  <code>127.0.0.1</code>.
98
94
  </p>
@@ -519,7 +515,7 @@
519
515
  >
520
516
  <div class="chat-tester-heading">
521
517
  <div>
522
- <p class="section-label">Local-only tester</p>
518
+ <p class="section-label">Streaming verification console</p>
523
519
  <h3 id="chat-tester-title">Test this local Chat Adapter</h3>
524
520
  <p>
525
521
  Relmio sends this browser only to its same-origin local wizard.
@@ -535,6 +531,14 @@
535
531
  </button>
536
532
  </div>
537
533
 
534
+ <div class="chat-tester-route" aria-label="Request route">
535
+ <span><strong>Browser</strong><small>Same origin</small></span>
536
+ <i aria-hidden="true">→</i>
537
+ <span><strong>Wizard relay</strong><small>Encrypted session</small></span>
538
+ <i aria-hidden="true">→</i>
539
+ <span><strong>Chat Adapter</strong><small>127.0.0.1 only</small></span>
540
+ </div>
541
+
538
542
  <form id="chat-tester-secure-form" class="chat-tester-form">
539
543
  <fieldset>
540
544
  <legend>Secure a temporary test session</legend>
package/src/ui/local.js CHANGED
@@ -131,6 +131,123 @@ async function api(path, { method = "GET", body } = {}) {
131
131
  return result;
132
132
  }
133
133
 
134
+ function parseRelmioStreamEvent(block) {
135
+ const dataLines = [];
136
+ let event;
137
+ for (const line of block.split(/\r?\n/u)) {
138
+ if (line.startsWith("event:")) {
139
+ event = line.slice(6).trim();
140
+ } else if (line.startsWith("data:")) {
141
+ dataLines.push(line.slice(5).trimStart());
142
+ }
143
+ }
144
+ if (!event || dataLines.length === 0) {
145
+ throw new Error("The local wizard returned an unreadable stream.");
146
+ }
147
+ let data;
148
+ try {
149
+ data = JSON.parse(dataLines.join("\n"));
150
+ } catch {
151
+ throw new Error("The local wizard returned an unreadable stream.");
152
+ }
153
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
154
+ throw new Error("The local wizard returned an unexpected stream event.");
155
+ }
156
+ return { event, data };
157
+ }
158
+
159
+ async function streamChatTesterMessage(body, onEvent) {
160
+ if (!token) {
161
+ throw new Error(
162
+ "This wizard link is incomplete. Close this tab and open the full URL printed by the active Relmio terminal.",
163
+ );
164
+ }
165
+ let response;
166
+ try {
167
+ response = await fetch("/api/local/chat-test/message", {
168
+ method: "POST",
169
+ headers: {
170
+ Accept: "text/event-stream",
171
+ "Content-Type": "application/json",
172
+ "X-Setup-Token": token,
173
+ },
174
+ body: JSON.stringify(body),
175
+ });
176
+ } catch {
177
+ throw new Error("The local Relmio wizard is not reachable. Keep its terminal open and try again.");
178
+ }
179
+ if (
180
+ !response.ok ||
181
+ !response.body ||
182
+ !/^text\/event-stream(?:\s*;|$)/iu.test(response.headers.get("content-type") ?? "") ||
183
+ response.headers.get("x-relmio-stream") !== "v1"
184
+ ) {
185
+ throw new Error("The local wizard could not start a safe response stream.");
186
+ }
187
+
188
+ const reader = response.body.getReader();
189
+ const decoder = new TextDecoder();
190
+ let buffer = "";
191
+ let exhausted = false;
192
+ let streamError;
193
+ let terminal;
194
+ const consume = (block) => {
195
+ if (!block.trim() || block.trimStart().startsWith(":")) return;
196
+ if (terminal) {
197
+ throw new Error("The local wizard sent data after the terminal event.");
198
+ }
199
+ const item = parseRelmioStreamEvent(block);
200
+ if (item.event === "start") return;
201
+ if (item.event === "progress" || item.event === "delta") {
202
+ onEvent(item.event, item.data);
203
+ return;
204
+ }
205
+ if (item.event === "error") {
206
+ const messages = {
207
+ timeout: "The adapter test took too long. Try again.",
208
+ upstream_failed: "The local adapter could not complete this response.",
209
+ };
210
+ streamError = messages[item.data.code] ?? messages.upstream_failed;
211
+ return;
212
+ }
213
+ if (item.event === "terminal") {
214
+ terminal = item.data;
215
+ return;
216
+ }
217
+ throw new Error("The local wizard returned an unexpected stream event.");
218
+ };
219
+
220
+ try {
221
+ for (;;) {
222
+ const { done, value } = await reader.read();
223
+ if (done) {
224
+ exhausted = true;
225
+ break;
226
+ }
227
+ buffer += decoder.decode(value, { stream: true });
228
+ const blocks = buffer.split(/\r?\n\r?\n/u);
229
+ buffer = blocks.pop() ?? "";
230
+ for (const block of blocks) consume(block);
231
+ }
232
+ buffer += decoder.decode();
233
+ if (buffer.trim()) consume(buffer);
234
+ } finally {
235
+ if (!exhausted) {
236
+ try {
237
+ await reader.cancel();
238
+ } catch {
239
+ // The same-origin stream may already have closed after a parse failure.
240
+ }
241
+ }
242
+ reader.releaseLock();
243
+ }
244
+
245
+ if (!terminal || terminal.outcome !== "completed" || typeof terminal.conversationId !== "string") {
246
+ throw new Error(streamError ?? "The adapter response ended before completion.");
247
+ }
248
+ return { conversationId: terminal.conversationId };
249
+ }
250
+
134
251
  function selectedTarget() {
135
252
  return document.querySelector('input[name="target"]:checked')?.value;
136
253
  }
@@ -341,6 +458,7 @@ function appendChatTesterTurn(kind, text) {
341
458
  item.className = `chat-tester-turn chat-tester-turn-${kind}`;
342
459
  item.append(heading, content);
343
460
  element("chat-tester-transcript").append(item);
461
+ return content;
344
462
  }
345
463
 
346
464
  function clearChatTesterState() {
@@ -756,13 +874,13 @@ element("chat-tester-message-form").addEventListener("submit", async (event) =>
756
874
  const generation = state.chatTester.generation;
757
875
  input.value = "";
758
876
  appendChatTesterTurn("user", text);
877
+ const assistantContent = appendChatTesterTurn("assistant", "");
759
878
  resetButton.disabled = true;
760
- setBusy(button, true, "Waiting for adapter…");
761
- setChatTesterStatus("The local wizard is testing the adapter without exposing its credential to the page.");
879
+ setBusy(button, true, "Streaming response…");
880
+ setChatTesterStatus("Connecting to the loopback adapter through the secured local wizard…");
762
881
  try {
763
- const result = await api("/api/local/chat-test/message", {
764
- method: "POST",
765
- body: {
882
+ const result = await streamChatTesterMessage(
883
+ {
766
884
  endpointBaseUrl: state.chatTester.endpointBaseUrl,
767
885
  keyId: state.chatTester.keyId,
768
886
  encryptedCredential: state.chatTester.encryptedCredential,
@@ -771,23 +889,37 @@ element("chat-tester-message-form").addEventListener("submit", async (event) =>
771
889
  ? { conversationId: state.chatTester.conversationId }
772
890
  : {}),
773
891
  },
774
- });
775
- if (
776
- typeof result.conversationId !== "string" ||
777
- typeof result.output !== "string"
778
- ) {
779
- throw new Error("The local adapter returned an unexpected response.");
780
- }
892
+ (event, data) => {
893
+ if (state.chatTester.generation !== generation) return;
894
+ if (event === "progress") {
895
+ setChatTesterStatus("The adapter is working on the response…");
896
+ } else if (event === "delta" && typeof data.text === "string") {
897
+ assistantContent.textContent += data.text;
898
+ setChatTesterStatus("Streaming the adapter response…");
899
+ }
900
+ },
901
+ );
781
902
  if (state.chatTester.generation !== generation) {
782
903
  return;
783
904
  }
784
905
  state.chatTester.conversationId = result.conversationId;
785
- appendChatTesterTurn("assistant", result.output);
906
+ if (!assistantContent.textContent) {
907
+ throw new Error("The local adapter completed without a visible response.");
908
+ }
786
909
  setChatTesterStatus("Response received. Continue this conversation or forget the tester.");
787
910
  } catch (error) {
788
911
  if (state.chatTester.generation === generation) {
912
+ if (!assistantContent.textContent) {
913
+ assistantContent.parentElement?.remove();
914
+ setChatTesterStatus("No completed adapter response was added. You can retry or forget this tester.");
915
+ } else {
916
+ const assistantTurn = assistantContent.parentElement;
917
+ assistantTurn?.classList.add("chat-tester-turn-incomplete");
918
+ const heading = assistantTurn?.querySelector("strong");
919
+ if (heading) heading.textContent = "Local adapter · incomplete";
920
+ setChatTesterStatus("The response stopped before completion. Partial text is marked incomplete and was not accepted.");
921
+ }
789
922
  showChatTesterError(error);
790
- setChatTesterStatus("No adapter response was added. You can retry or forget this tester.");
791
923
  }
792
924
  } finally {
793
925
  resetButton.disabled = false;
Binary file
package/src/ui/styles.css CHANGED
@@ -1126,15 +1126,11 @@ footer p {
1126
1126
  /* ---------- Dark mode refinement ---------- */
1127
1127
 
1128
1128
  :root[data-theme="dark"] .brand-mark {
1129
- padding: 0.125rem;
1130
- background: #f7f8f5;
1131
1129
  box-shadow: 0 0 0 1px rgb(255 255 255 / 14%);
1132
1130
  }
1133
1131
 
1134
1132
  @media (prefers-color-scheme: dark) {
1135
1133
  :root:not([data-theme]) .brand-mark {
1136
- padding: 0.125rem;
1137
- background: #f7f8f5;
1138
1134
  box-shadow: 0 0 0 1px rgb(255 255 255 / 14%);
1139
1135
  }
1140
1136
  }
package/src/web/server.js CHANGED
@@ -115,6 +115,64 @@ function sendJson(response, statusCode, body) {
115
115
  response.end(contents);
116
116
  }
117
117
 
118
+ function requestAcceptsEventStream(request) {
119
+ const value = request.headers.accept;
120
+ return (
121
+ typeof value === "string" &&
122
+ value
123
+ .split(",")
124
+ .some((entry) => entry.trim().split(";", 1)[0] === "text/event-stream")
125
+ );
126
+ }
127
+
128
+ function startLocalChatTestStream(response) {
129
+ let ended = false;
130
+ response.writeHead(200, {
131
+ "Content-Encoding": "none",
132
+ "Content-Type": "text/event-stream; charset=utf-8",
133
+ "X-Accel-Buffering": "no",
134
+ "X-Relmio-Stream": "v1",
135
+ });
136
+ const send = (event, data) => {
137
+ if (ended || response.writableEnded || response.destroyed) return;
138
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
139
+ };
140
+ const keepalive = setInterval(() => {
141
+ if (!ended && !response.writableEnded && !response.destroyed) {
142
+ response.write(": keepalive\n\n");
143
+ }
144
+ }, 15_000);
145
+ keepalive.unref?.();
146
+ send("start", { requestId: randomUUID() });
147
+ return {
148
+ send(event, data) {
149
+ if (event === "progress" || event === "delta") send(event, data);
150
+ },
151
+ complete(result) {
152
+ if (ended) return;
153
+ send("terminal", {
154
+ outcome: "completed",
155
+ conversationId: result.conversationId,
156
+ });
157
+ ended = true;
158
+ clearInterval(keepalive);
159
+ response.end();
160
+ },
161
+ fail(code = "upstream_failed") {
162
+ if (ended) return;
163
+ send("error", { code, retryable: true });
164
+ send("terminal", { outcome: "failed" });
165
+ ended = true;
166
+ clearInterval(keepalive);
167
+ response.end();
168
+ },
169
+ dispose() {
170
+ ended = true;
171
+ clearInterval(keepalive);
172
+ },
173
+ };
174
+ }
175
+
118
176
  function tokenMatches(actual, expected) {
119
177
  if (typeof actual !== "string") {
120
178
  return false;
@@ -305,6 +363,7 @@ async function loadDefaultUiFiles() {
305
363
  readFile(new URL("../ui/icons/monitor.svg", import.meta.url), "utf8"),
306
364
  readFile(new URL("../ui/icons/sun.svg", import.meta.url), "utf8"),
307
365
  readFile(new URL("../ui/icons/moon.svg", import.meta.url), "utf8"),
366
+ readFile(new URL("../ui/relmio-icon.png", import.meta.url)),
308
367
  ]);
309
368
 
310
369
  return {
@@ -320,6 +379,7 @@ async function loadDefaultUiFiles() {
320
379
  "/icons/monitor.svg": files[9],
321
380
  "/icons/sun.svg": files[10],
322
381
  "/icons/moon.svg": files[11],
382
+ "/relmio-icon.png": files[12],
323
383
  };
324
384
  }
325
385
 
@@ -691,13 +751,35 @@ async function handleApi(request, response, path, state) {
691
751
  requireLiveLocalAction(state, "Local chat testing");
692
752
  requireReadyLocalChatTester(state);
693
753
  enforceRateLimit(state, path);
754
+ const wantsStream = requestAcceptsEventStream(request);
755
+ const stream = wantsStream ? startLocalChatTestStream(response) : null;
756
+ const requestController = wantsStream ? new AbortController() : null;
757
+ const abortOnClose = () => {
758
+ if (!response.writableEnded) requestController?.abort();
759
+ };
760
+ response.once("close", abortOnClose);
694
761
  try {
695
- sendJson(
696
- response,
697
- 200,
698
- createSafeLocalChatTestResponse(await state.localChatTest.message(body)),
762
+ const result = createSafeLocalChatTestResponse(
763
+ await state.localChatTest.message(body, {
764
+ ...(stream
765
+ ? {
766
+ onEvent: (event, data) => stream.send(event, data),
767
+ signal: requestController.signal,
768
+ }
769
+ : {}),
770
+ }),
699
771
  );
772
+ if (stream) stream.complete(result);
773
+ else sendJson(response, 200, result);
774
+ } catch (error) {
775
+ if (stream) {
776
+ stream.fail(error?.statusCode === 504 ? "timeout" : "upstream_failed");
777
+ } else {
778
+ throw error;
779
+ }
700
780
  } finally {
781
+ response.off("close", abortOnClose);
782
+ stream?.dispose();
701
783
  if (body && typeof body === "object") {
702
784
  body.encryptedCredential = undefined;
703
785
  body.input = undefined;
@@ -1161,9 +1243,11 @@ function createRequestHandler(state) {
1161
1243
  ? "text/javascript; charset=utf-8"
1162
1244
  : path.endsWith(".svg")
1163
1245
  ? "image/svg+xml; charset=utf-8"
1164
- : path.endsWith(".css")
1165
- ? "text/css; charset=utf-8"
1166
- : "text/html; charset=utf-8";
1246
+ : path.endsWith(".png")
1247
+ ? "image/png"
1248
+ : path.endsWith(".css")
1249
+ ? "text/css; charset=utf-8"
1250
+ : "text/html; charset=utf-8";
1167
1251
  const contents = state.uiFiles[path];
1168
1252
  response.writeHead(200, {
1169
1253
  "Content-Type": contentType,
@@ -1,16 +0,0 @@
1
- <svg
2
- xmlns="http://www.w3.org/2000/svg"
3
- viewBox="0 0 64 64"
4
- role="img"
5
- aria-labelledby="relmio-mark-title"
6
- >
7
- <title id="relmio-mark-title">Relmio mark</title>
8
- <path
9
- fill="#137c74"
10
- d="M7 8h19l31 24H41L26 20v10L7 17Z"
11
- />
12
- <path
13
- fill="#12211f"
14
- d="M7 47l19-13v10l15-12h16L26 56H7Z"
15
- />
16
- </svg>