File size: 6,501 Bytes
1944112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
/**
 * WebExtension adapter: the storage passthrough, and the message/port router.
 *
 * This is the whole extension-specific half of what used to be
 * `src/background/background.js`. The engine no longer knows it exists — it is
 * one way to reach a `ScheduledEngine`, and a page reaches the same object by
 * calling its methods.
 *
 * The wire format is unchanged byte-for-byte from `everything-webgpu/v1`, so
 * the popup, the manager page, the e2e devtest page and any external extension
 * keep working with no edits.
 */
import { asEngineError } from "../engine/errors.js";
import { OP, PORT_NAME, PORT_OP, PROTOCOL } from "./protocol.js";

/**
 * `browser.storage.local` already *is* the StorageAdapter shape — `get(key)`
 * and `set(obj)`. The two-method interface was chosen for that reason, so this
 * is a passthrough rather than a translation layer.
 *
 * @returns {import("../engine/model-store.js").StorageAdapter}
 */
export function webExtensionStorage() {
  return browser.storage.local;
}

/**
 * Wires a ScheduledEngine onto this extension's runtime messaging.
 *
 * @param {import("../engine/engine.js").ScheduledEngine} engine
 * @returns {() => void} detach
 */
export function attachWebExtensionTransport(engine) {
  const subscribers = new Set();

  const unsubscribe = engine.subscribe((state) => {
    broadcast({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state });
  });

  function broadcast(msg) {
    for (const port of subscribers) {
      try {
        port.postMessage(msg);
      } catch {
        subscribers.delete(port);
      }
    }
  }

  // ------------------------------------------------------------- routing ----

  async function handle(msg) {
    if (!msg || msg.protocol !== PROTOCOL) {
      throw new Error(`Expected protocol "${PROTOCOL}", got "${msg && msg.protocol}".`);
    }
    switch (msg.op) {
      case OP.STATUS:
        return { ok: true, state: engine.state, webgpu: engine.hasWebGPU };
      case OP.LIST_MODELS:
        return { ok: true, models: await engine.listModels(), state: engine.state };
      case OP.LOAD:
        return { ok: true, state: await engine.load(msg.modelId) };
      case OP.UNLOAD:
        return { ok: true, state: await engine.unload() };
      case OP.CHAT:
        return { ok: true, ...(await engine.complete(msg)) };
      case OP.BATCH:
        return { ok: true, results: await engine.batch(msg) };
      case OP.CANCEL:
        return { ok: true, cancelled: engine.cancel(msg.id ?? msg.session) };
      case OP.CONFIGURE:
        return { ok: true, ...(await engine.configure(msg)) };
      default:
        throw new Error(`Unknown op "${msg.op}".`);
    }
  }

  /**
   * `error` stays a plain string so every existing caller keeps working; `code`
   * and `detail` are added beside it. Drop the string once consumers have
   * moved — the wire protocol is versioned (`everything-webgpu/v1`) and this is
   * an addition, not a break.
   */
  const respond = (msg) =>
    handle(msg).catch((err) => {
      const e = asEngineError(err);
      return { ok: false, error: e.message, code: e.code, ...(e.detail ? { detail: e.detail } : {}) };
    });

  /**
   * The allowlist is a property of *this transport*, not of the engine: it
   * answers "which other extension may send me messages", a question that has
   * no meaning for a page that already holds the object.
   */
  async function denyExternal(sender) {
    const { allowedExternalIds } = await engine.store.getSettings();
    const id = sender?.id;
    if (allowedExternalIds.length === 0 || allowedExternalIds.includes(id)) return null;
    return { ok: false, error: `Extension "${id}" is not on this engine's allowlist.` };
  }

  function attachPort(port) {
    if (port.name !== PORT_NAME) return;
    subscribers.add(port);
    port.onDisconnect.addListener(() => subscribers.delete(port));
    port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });

    const send = (msg) => {
      try {
        port.postMessage(msg);
      } catch {
        /* port closed mid-stream */
      }
    };

    port.onMessage.addListener(async (msg) => {
      const id = msg?.id;
      try {
        if (!msg || msg.protocol !== PROTOCOL) throw new Error(`Expected protocol "${PROTOCOL}".`);
        switch (msg.op) {
          case PORT_OP.SUBSCRIBE:
            return send({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });

          case PORT_OP.CHAT_STREAM: {
            const result = await engine.complete(msg, (delta) =>
              send({ protocol: PROTOCOL, op: PORT_OP.CHUNK, id, delta }),
            );
            return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, ...result });
          }

          case PORT_OP.BATCH_STREAM: {
            const results = await engine.batch(msg, (item) =>
              send({ protocol: PROTOCOL, op: PORT_OP.ITEM, id, ...item }),
            );
            return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, results });
          }

          case PORT_OP.ABORT:
            return void engine.cancel(msg.session ?? id);

          default:
            return send({ protocol: PROTOCOL, op: msg.op, id, ...(await respond(msg)) });
        }
      } catch (err) {
        const e = asEngineError(err);
        send({
          protocol: PROTOCOL,
          op: PORT_OP.ERROR,
          id,
          error: e.message,
          code: e.code,
          ...(e.detail ? { detail: e.detail } : {}),
        });
      }
    });
  }

  const onMessage = (msg) => respond(msg);
  const onMessageExternal = async (msg, sender) => (await denyExternal(sender)) ?? respond(msg);
  const onConnectExternal = async (port) => {
    const denial = await denyExternal(port.sender);
    if (denial) {
      port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ERROR, error: denial.error });
      port.disconnect();
      return;
    }
    attachPort(port);
  };

  browser.runtime.onMessage.addListener(onMessage);
  browser.runtime.onMessageExternal.addListener(onMessageExternal);
  browser.runtime.onConnect.addListener(attachPort);
  browser.runtime.onConnectExternal.addListener(onConnectExternal);

  return () => {
    browser.runtime.onMessage.removeListener(onMessage);
    browser.runtime.onMessageExternal.removeListener(onMessageExternal);
    browser.runtime.onConnect.removeListener(attachPort);
    browser.runtime.onConnectExternal.removeListener(onConnectExternal);
    unsubscribe();
    subscribers.clear();
  };
}