@embededitor/relay 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ Every change to `@embededitor/relay`, newest first.
4
+
5
+ ## [0.1.0] - 2026-11-11
6
+
7
+ ### Added
8
+
9
+ - The relay of Embed Editor's agent link, for one process on your own server: `createRelay` takes its settings as plain values (`allowedOrigins`, `agentSessionSecret`, `publicOrigin`) and gives the relay served on its own (`listen`), or its Hono app and the editor's socket server to serve yourself.
package/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 Rohde Softwares
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # @embededitor/relay
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The relay of [Embed Editor](https://embededitor.com)'s agent link, to run on your own server.
4
+
5
+ With the agent link, your users' own AI agent (Claude, ChatGPT, Codex, Cursor) edits the photo open in your editor. They copy a link from the editor, paste it into their agent with a request ("blur the faces"), and watch each change land. The relay is the small server in between: the editor connects to it, the agent calls it over MCP, and it passes each call to the editor and the answer back. It keeps no photo and no call.
6
+
7
+ You don't need it to use the agent link: every editor links through Embed Editor's relay, at `https://agent.embededitor.com`. Run your own when:
8
+
9
+ - the link and How to Connect must show your own address (a white-label editor);
10
+ - only your signed-in users may make a link;
11
+ - the agent's calls must pass only through your own servers.
12
+
13
+ **Docs:** [Your Own Relay](https://docs.embededitor.com/agent-link/relay), step by step · [Agent Link](https://docs.embededitor.com/agent-link/overview) · [Protocol](https://docs.embededitor.com/agent-link/protocol)
14
+
15
+ > **Prototype.** The agent link isn't in a released edition yet, and this package may change before it is.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @embededitor/relay
21
+ ```
22
+
23
+ It runs on Node 20 or later.
24
+
25
+ ## Use
26
+
27
+ ```js
28
+ import { createRelay } from '@embededitor/relay';
29
+
30
+ const relay = createRelay({
31
+ agentSessionSecret: null,
32
+ allowedOrigins: ['https://shop.example.com'],
33
+ publicOrigin: 'https://agent.shop.example.com'
34
+ });
35
+
36
+ relay.listen(8788);
37
+ ```
38
+
39
+ Then:
40
+
41
+ 1. Put the relay at a public HTTPS address of its own, like `https://agent.shop.example.com`, behind your reverse proxy or hosting platform. It needs WebSockets.
42
+ 2. Give your editor that address, with the agent link on: `<EmbedEditor agentRelay='https://agent.shop.example.com' enableAgent />`.
43
+ 3. Connect your agent to `https://agent.shop.example.com/mcp`. In Claude Code: `claude mcp add --transport http photo-editor https://agent.shop.example.com/mcp`. The editor's **How to Connect** shows your users the same steps.
44
+
45
+ ## Settings
46
+
47
+ `createRelay` takes plain values. Write them in the code, or read them from wherever you keep your configuration.
48
+
49
+ | Setting | What it does |
50
+ |---|---|
51
+ | `allowedOrigins` | The pages whose editor makes its own link: an exact origin (`'https://shop.example.com'`), every subdomain of a domain (`'https://*.shop.example.com'`, not the domain itself), or `'*'` for every page. `[]`: no page makes one. |
52
+ | `agentSessionSecret` | A secret your backend sends as `Authorization: Bearer <secret>` to make a link for a signed-in user, so no page makes one on its own. A value from `openssl rand -hex 32`, kept in your secret store, never in git or a page. `null`: no backend makes one. |
53
+ | `publicOrigin` | The address the links name, the one your users' browsers and agents reach. `null`: the address each request came in on. Set it behind a proxy, or when your backend calls the relay on an internal address. |
54
+
55
+ With no page in `allowedOrigins` and no `agentSessionSecret`, the relay makes no link.
56
+
57
+ For links made by your backend, for signed-in users only, with the editor's `onAgentLink`, see [Only Signed-In Users](https://docs.embededitor.com/agent-link/relay#only-signed-in-users).
58
+
59
+ ## In Your Own Server
60
+
61
+ `createRelay` also gives the relay's [Hono](https://hono.dev) app and the editor's socket server, to serve them yourself:
62
+
63
+ ```js
64
+ import { createRelay } from '@embededitor/relay';
65
+ import { serve } from '@hono/node-server';
66
+
67
+ const { app, websocket } = createRelay({ agentSessionSecret: null, allowedOrigins: ['https://shop.example.com'], publicOrigin: null });
68
+
69
+ serve({ fetch: app.fetch, port: 8788, websocket });
70
+ ```
71
+
72
+ The relay answers at the root of its address, so give it a host of its own. Every link lives in the process's memory: run one process per relay.
73
+
74
+ ## Limits
75
+
76
+ | Limit | Value |
77
+ |---|---|
78
+ | A link's lifetime | 2 hours |
79
+ | A tab away before its link ends | 2 minutes |
80
+ | A call's wait for the editor | 55 s |
81
+ | An ended link's record, so a late call hears why | 1 hour |
82
+ | A message from the editor (a whole-photo screenshot is about 113 KB) | 8 MiB |
83
+
84
+ The relay keeps only each link token's SHA-256. Its log names each command, its time and its size, never the token or what a call carried.
85
+
86
+ ## License
87
+
88
+ MIT, © Rohde Softwares.
@@ -0,0 +1,9 @@
1
+ declare const createRelay: ({ agentSessionSecret, allowedOrigins, publicOrigin }: import("./relay.js").RelaySettings) => {
2
+ app: import("hono").Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
3
+ listen: (port: number) => import("@hono/node-server").ServerType;
4
+ websocket: {
5
+ server: import("ws").Server<typeof import("ws").default, typeof import("http").IncomingMessage>;
6
+ };
7
+ };
8
+ export type { RelaySettings } from './relay.js';
9
+ export { createRelay };
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // @embededitor/relay: Embed Editor's agent link relay, to run on your own server (README.md,
2
+ // https://docs.embededitor.com/agent-link/relay)
3
+ import relay from './relay.js';
4
+ const { createRelay } = relay;
5
+ export { createRelay };
@@ -0,0 +1,17 @@
1
+ import { Hono } from 'hono';
2
+ type RelaySettings = {
3
+ agentSessionSecret: string | null;
4
+ allowedOrigins: string[];
5
+ publicOrigin: string | null;
6
+ };
7
+ export type { RelaySettings };
8
+ declare const _default: {
9
+ createRelay: ({ agentSessionSecret, allowedOrigins, publicOrigin }: RelaySettings) => {
10
+ app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
11
+ listen: (port: number) => import("@hono/node-server").ServerType;
12
+ websocket: {
13
+ server: import("ws").Server<typeof import("ws").default, typeof import("http").IncomingMessage>;
14
+ };
15
+ };
16
+ };
17
+ export default _default;
package/dist/relay.js ADDED
@@ -0,0 +1,547 @@
1
+ // Embed Editor's agent link relay in one process, on Hono, built from the protocol page (docs/agent-link/protocol.mdx),
2
+ // published as @embededitor/relay. Every session lives in this process's memory, a map from the token's hash to its
3
+ // session, so a process holds one relay. createRelay takes its settings as plain values, from wherever the integrator
4
+ // keeps them, and gives the relay served on its own (listen) or the Hono app and the editor's socket server to mount in
5
+ // their own server. Everything is web standard (Request, Response, Web Crypto) but Node's server and the editor's
6
+ // socket: @hono/node-server's serve and upgradeWebSocket, with ws under them
7
+ import { bodyLimit } from 'hono/body-limit';
8
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ import { Hono } from 'hono';
10
+ import { serve, upgradeWebSocket } from '@hono/node-server';
11
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
+ import { WebSocketServer } from 'ws';
13
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
14
+ const BEARER = /^Bearer\s+(\S+)$/i;
15
+ const CALL_TIMEOUT = 55 * 1000;
16
+ const CLOSE_ENDED = 4000;
17
+ const CLOSE_REPLACED = 4001;
18
+ const CLOSE_SILENT = 4002;
19
+ // the end of a link may come from any page: its token is its proof
20
+ const END_CORS_HEADERS = { 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-origin': '*' };
21
+ const ENDED_KEPT = 60 * 60 * 1000;
22
+ const ENDED_TEXTS = {
23
+ DISCONNECTED: 'The person disconnected you from their editor. Ask them for a new link if they want you to keep editing.',
24
+ EXPIRED: 'This editor link expired. Ask the person for a new link from their editor.',
25
+ TAB_CLOSED: "The editor's tab was closed, so this link ended. Ask the person for a new link from their editor."
26
+ };
27
+ // the allowedOrigins entry that names every page, whatever its origin: a relay open to any site's editor
28
+ const EVERY_PAGE = '*';
29
+ const FORBIDDEN = {
30
+ error: 'FORBIDDEN',
31
+ message: "A link is made by the editor on a page this relay allows (allowedOrigins), or by your server with the relay's secret (Authorization: Bearer <agentSessionSecret>)."
32
+ };
33
+ const GUIDE = [
34
+ "You are connected to the person's Embed Editor, open in their browser tab. They watch every change you make as you make it.",
35
+ 'How to work:',
36
+ "1. Call read_size for the image's size, then read_screenshot to see the photo. Every coordinate a command takes is in image px, as read_size gives them; each screenshot comes with a note saying how its px map to image px.",
37
+ '2. After you place something, look again (read_screenshot, with `area` to zoom on the spot) and correct it with the update command of what you placed.',
38
+ "3. Every edit is one undo step: history_undo takes back the last one, yours or the person's.",
39
+ '4. Save (output_save) only when the person asks for it.',
40
+ 'Each command below runs with run_command: its name, and its input as its inputSchema says.'
41
+ ].join('\n');
42
+ // the subdomain a pattern's `*` stands for: one or more labels, nothing a port, a path or credentials could hide in
43
+ const LABELS = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
44
+ const LINK_LIFETIME = 2 * 60 * 60 * 1000;
45
+ // the page a person sees opening the link in a browser: the MCP address from the page's own origin, and the token after
46
+ // the # never reaches the relay
47
+ const LINK_PAGE = [
48
+ '<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
49
+ '<title>Embed Editor agent link</title>',
50
+ '<body style="background:#111;color:#eee;font:16px/1.5 system-ui,sans-serif;margin:0;padding:32px;max-width:640px">',
51
+ '<h1 style="font-size:20px">This is an Embed Editor agent link</h1>',
52
+ "<p>Paste it into an AI agent that has the Embed Editor MCP server connected (Claude Desktop, Claude Code). The agent then edits the photo open in your editor's tab, and you watch it happen there.</p>",
53
+ '<p>The MCP server\'s address is <code id="mcp"></code>.</p>',
54
+ "<script>document.getElementById('mcp').textContent = location.origin + '/mcp'</script>",
55
+ '</body></html>'
56
+ ].join('');
57
+ // the body of POST /sessions/end is a token and a flag: anything far larger is not one
58
+ const MAX_END_BODY = 4096;
59
+ // a 1024 px JPEG in base64, inside JSON, with room to spare
60
+ const MAX_MESSAGE = 8 * 1024 * 1024;
61
+ const NO_NAME = "run_command takes the command's name, as list_commands gives it.";
62
+ const NOT_A_LINK = "That isn't an Embed Editor link. Ask the person to copy the link the editor shows, and pass it whole.";
63
+ const NOT_CONNECTED = "The editor's tab is not connected right now (it may be in the background, asleep, or offline). Ask the person to bring the tab back, then try again.";
64
+ const NOT_VALID = "This editor link isn't valid. Ask the person to copy it again from their editor.";
65
+ // what an MCP client hears for anything but a POST: a GET would open a stream nothing writes to, and there is no session
66
+ // to DELETE
67
+ const ONLY_POST = { error: { code: -32000, message: 'Method not allowed: this server takes a POST for each call.' }, id: null, jsonrpc: '2.0' };
68
+ const PROTOCOL = 'ee-agent';
69
+ const RECONNECT_GRACE = 2 * 60 * 1000;
70
+ const SOCKET_STALE = 25 * 1000;
71
+ // an allowedOrigins entry for every subdomain of a domain, `scheme://*.domain[:port]`: its scheme and its domain captured
72
+ const SUBDOMAINS = /^([a-z][a-z0-9+.-]*):\/\/\*\.(.+)$/i;
73
+ const TOKEN = /^[A-Za-z0-9_-]{40,}$/;
74
+ const encoder = new TextEncoder();
75
+ const sessions = new Map();
76
+ // the editor's sockets on Node: the subprotocol ee-agent echoed, as a browser requires, and up to MAX_MESSAGE a message
77
+ const wss = new WebSocketServer({
78
+ handleProtocols: () => {
79
+ return PROTOCOL;
80
+ },
81
+ maxPayload: MAX_MESSAGE,
82
+ noServer: true
83
+ });
84
+ // the editor's socket on its session: a newer one replaces the open one (4001), and one on a session that ended or
85
+ // doesn't exist is closed at once with 4000 and why. Whether the session took it
86
+ const acceptSocket = ({ hash, ws }) => {
87
+ const session = sessions.get(hash);
88
+ if (!session || session.endedReason) {
89
+ ws.close(CLOSE_ENDED, session?.endedReason ?? 'EXPIRED');
90
+ return false;
91
+ }
92
+ const replaced = session.socket;
93
+ clearTimeout(session.graceTimer);
94
+ session.heardAt = Date.now();
95
+ session.socket = ws;
96
+ replaced?.close(CLOSE_REPLACED, 'REPLACED');
97
+ if (session.connected) {
98
+ sendMessage(ws, { type: 'AGENT_CONNECTED' });
99
+ }
100
+ return true;
101
+ };
102
+ // the request's Origin when an allowedOrigins entry names it (covers), or null
103
+ const allowedOrigin = ({ allowedOrigins, headers }) => {
104
+ const origin = headers.get('origin');
105
+ const listed = allowedOrigins.some(entry => {
106
+ return !!origin && covers({ entry, origin });
107
+ });
108
+ return origin && listed ? origin : null;
109
+ };
110
+ // Authorization: Bearer <agentSessionSecret>: both hashed and the digests compared with no early exit, so the answer's
111
+ // time says nothing of how much matched. Without a secret, never
112
+ const authorized = async ({ agentSessionSecret, headers }) => {
113
+ const authorization = headers.get('authorization') ?? '';
114
+ const match = BEARER.exec(authorization);
115
+ if (!agentSessionSecret || !match) {
116
+ return false;
117
+ }
118
+ const [given, expected] = await Promise.all([digest(match[1]), digest(agentSessionSecret)]);
119
+ let diff = 0;
120
+ for (let i = 0; i < given.length; i += 1) {
121
+ diff |= given[i] ^ expected[i];
122
+ }
123
+ return diff === 0;
124
+ };
125
+ // one run_command: the CALL to the editor, and its result, or why none came within 55 s
126
+ const callEditor = ({ input, name, session, socket }) => {
127
+ const id = crypto.randomUUID();
128
+ return new Promise(resolve => {
129
+ const timer = setTimeout(() => {
130
+ const res = errorResult(`The editor didn't answer ${name} within ${CALL_TIMEOUT / 1000} s. Take a screenshot to see whether it ran.`);
131
+ session.calls.delete(id);
132
+ resolve(res);
133
+ }, CALL_TIMEOUT);
134
+ const settle = (res) => {
135
+ clearTimeout(timer);
136
+ session.calls.delete(id);
137
+ resolve(res);
138
+ };
139
+ session.calls.set(id, { name, settle });
140
+ sendMessage(socket, { id, input, name, type: 'CALL' });
141
+ });
142
+ };
143
+ // a tools/call: the link first, then the tool; run_command's name before its session
144
+ const callTool = async ({ arguments: args = {}, name }) => {
145
+ const token = readToken(args.link);
146
+ if (!token) {
147
+ return errorResult(NOT_A_LINK);
148
+ }
149
+ const hash = await hashToken(token);
150
+ if (name === 'list_commands') {
151
+ return listCommands(hash);
152
+ }
153
+ if (name === 'run_command') {
154
+ return runCommand({ args, hash });
155
+ }
156
+ return errorResult(`No tool ${name}: this server has list_commands and run_command.`);
157
+ };
158
+ // whether an allowedOrigins entry names a page's origin: `*` every one, else that origin exactly, or, for
159
+ // `scheme://*.domain[:port]`, an origin of that scheme on a subdomain of that domain, at that port (never the domain
160
+ // itself)
161
+ const covers = ({ entry, origin }) => {
162
+ if (entry === EVERY_PAGE) {
163
+ return true;
164
+ }
165
+ const [, scheme = '', domain = ''] = SUBDOMAINS.exec(entry) ?? [];
166
+ if (!scheme) {
167
+ return entry === origin;
168
+ }
169
+ const prefix = `${scheme}://`;
170
+ const suffix = `.${domain}`;
171
+ if (!origin.startsWith(prefix) || !origin.endsWith(suffix)) {
172
+ return false;
173
+ }
174
+ const subdomain = origin.slice(prefix.length, origin.length - suffix.length);
175
+ return LABELS.test(subdomain);
176
+ };
177
+ // a fresh MCP server for one request, as stateless Streamable HTTP has it
178
+ const createMcpServer = (origin) => {
179
+ const text = instructions(origin);
180
+ const tools = mcpTools(origin);
181
+ const server = new Server({ name: 'embed-editor', version: '0.0.0' }, { capabilities: { tools: {} }, instructions: text });
182
+ server.setRequestHandler(ListToolsRequestSchema, () => {
183
+ return { tools };
184
+ });
185
+ server.setRequestHandler(CallToolRequestSchema, req => {
186
+ return callTool(req.params);
187
+ });
188
+ return server;
189
+ };
190
+ // the relay on `settings`: served on its own (listen), or its routes on a Hono app and the editor's socket server, for a
191
+ // server of yours to serve (serve's `fetch` and `websocket`) or mount
192
+ const createRelay = ({ agentSessionSecret, allowedOrigins, publicOrigin }) => {
193
+ const app = new Hono();
194
+ // makes a link, for a page in allowedOrigins or a backend with the secret
195
+ app.all('/sessions', async (c) => {
196
+ const origin = allowedOrigin({ allowedOrigins, headers: c.req.raw.headers });
197
+ const headers = { vary: 'origin' };
198
+ if (origin) {
199
+ headers['access-control-allow-headers'] = 'content-type';
200
+ headers['access-control-allow-methods'] = 'POST, OPTIONS';
201
+ headers['access-control-allow-origin'] = origin;
202
+ }
203
+ if (c.req.method === 'OPTIONS') {
204
+ return c.body(null, 204, headers);
205
+ }
206
+ if (c.req.method !== 'POST') {
207
+ return c.text('Method not allowed', 405, headers);
208
+ }
209
+ const allowed = await authorized({ agentSessionSecret, headers: c.req.raw.headers });
210
+ if (!origin && !allowed) {
211
+ return c.json(FORBIDDEN, 403, headers);
212
+ }
213
+ const { session, token } = await createSession();
214
+ const relay = relayOrigin({ publicOrigin, url: c.req.url });
215
+ return c.json({ expiresAt: session.expiresAt, link: `${relay}/link#${token}`, token }, 200, headers);
216
+ });
217
+ // ends a link at once: the person's Disconnect, or the tab closing (closed: true), by fetch or sendBeacon as text
218
+ app.all('/sessions/end', bodyLimit({
219
+ maxSize: MAX_END_BODY,
220
+ onError: c => {
221
+ return c.text('Expected a token', 400, END_CORS_HEADERS);
222
+ }
223
+ }), async (c) => {
224
+ if (c.req.method === 'OPTIONS') {
225
+ return c.body(null, 204, END_CORS_HEADERS);
226
+ }
227
+ const text = c.req.method === 'POST' ? await c.req.text() : '';
228
+ const body = readEndBody(text);
229
+ if (!body) {
230
+ return c.text('Expected a token', 400, END_CORS_HEADERS);
231
+ }
232
+ const hash = await hashToken(body.token);
233
+ const session = sessions.get(hash);
234
+ if (session) {
235
+ endSession(session, body.closed ? 'TAB_CLOSED' : 'DISCONNECTED');
236
+ }
237
+ return c.body(null, 204, END_CORS_HEADERS);
238
+ });
239
+ // the editor's socket: 400 for anything but its upgrade, which is accepted whatever its session's state
240
+ app.all('/socket', async (c) => {
241
+ const token = socketToken(c.req.raw.headers);
242
+ if (!token) {
243
+ return c.text('Expected the editor socket', 400);
244
+ }
245
+ const hash = await hashToken(token);
246
+ const events = socketEvents(hash);
247
+ return upgradeWebSocket(c, events);
248
+ });
249
+ // MCP's Streamable HTTP, stateless: a fresh server and transport for each request (no session id generator), answered
250
+ // as JSON
251
+ app.post('/mcp', async (c) => {
252
+ const origin = relayOrigin({ publicOrigin, url: c.req.url });
253
+ const server = createMcpServer(origin);
254
+ const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
255
+ await server.connect(transport);
256
+ return transport.handleRequest(c.req.raw);
257
+ });
258
+ app.all('/mcp', c => {
259
+ return c.json(ONLY_POST, 405, { allow: 'POST' });
260
+ });
261
+ // the page a person sees opening a link in a browser, for any method
262
+ app.all('/', c => {
263
+ return c.html(LINK_PAGE);
264
+ });
265
+ app.all('/link', c => {
266
+ return c.html(LINK_PAGE);
267
+ });
268
+ app.notFound(c => {
269
+ return c.text('Not found', 404);
270
+ });
271
+ app.onError((err, c) => {
272
+ console.error(err);
273
+ return c.text('Internal error', 500);
274
+ });
275
+ // the relay served on its own on `port`: its routes and the editor's socket, its Node server given back
276
+ const listen = (port) => {
277
+ return serve({ fetch: app.fetch, port, websocket: { server: wss } });
278
+ };
279
+ return { app, listen, websocket: { server: wss } };
280
+ };
281
+ // a new session, waiting for its socket, and its token (32 random bytes as base64url): only the token's hash is kept
282
+ const createSession = async () => {
283
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
284
+ const binary = String.fromCharCode(...bytes);
285
+ const base64 = btoa(binary);
286
+ const token = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
287
+ const hash = await hashToken(token);
288
+ const session = {
289
+ calls: new Map(),
290
+ commands: [],
291
+ connected: false,
292
+ endedReason: null,
293
+ expiresAt: Date.now() + LINK_LIFETIME,
294
+ expireTimer: scheduleEnd({ hash, ms: LINK_LIFETIME, reason: 'EXPIRED' }),
295
+ graceTimer: scheduleEnd({ hash, ms: RECONNECT_GRACE, reason: 'TAB_CLOSED' }),
296
+ hash,
297
+ heardAt: 0,
298
+ socket: null
299
+ };
300
+ sessions.set(hash, session);
301
+ return { session, token };
302
+ };
303
+ // a text's SHA-256, as bytes
304
+ const digest = async (text) => {
305
+ const bytes = encoder.encode(text);
306
+ const buffer = await crypto.subtle.digest('SHA-256', bytes);
307
+ return new Uint8Array(buffer);
308
+ };
309
+ // the session's end: ENDED and a close with 4000 for its socket, the ended sentence for every call on its way, and its
310
+ // record kept an hour, so a late call hears why
311
+ const endSession = (session, reason) => {
312
+ if (session.endedReason) {
313
+ return;
314
+ }
315
+ clearTimeout(session.expireTimer);
316
+ clearTimeout(session.graceTimer);
317
+ session.endedReason = reason;
318
+ settleCalls(session, () => {
319
+ return ENDED_TEXTS[reason];
320
+ });
321
+ if (session.socket) {
322
+ sendMessage(session.socket, { reason, type: 'ENDED' });
323
+ session.socket.close(CLOSE_ENDED, reason);
324
+ session.socket = null;
325
+ }
326
+ setTimeout(() => {
327
+ sessions.delete(session.hash);
328
+ }, ENDED_KEPT);
329
+ };
330
+ const errorResult = (text) => {
331
+ return { content: [{ text, type: 'text' }], isError: true };
332
+ };
333
+ // the session's name: the SHA-256 of the token's text, as 64 lowercase hex characters
334
+ const hashToken = async (token) => {
335
+ const bytes = await digest(token);
336
+ const hex = Array.from(bytes, byte => {
337
+ return byte.toString(16).padStart(2, '0');
338
+ });
339
+ return hex.join('');
340
+ };
341
+ const instructions = (origin) => {
342
+ return [
343
+ "This server drives a person's Embed Editor (an image editor open in their browser tab) through the editor's own commands.",
344
+ `The person gives you a link from the editor, like ${origin}/link#… — call list_commands with it first, then run_command for each command, the link in every call.`,
345
+ 'The commands annotate the photo (arrows, texts, boxes), blur or pixelate areas, adjust, filter, crop, frame, undo and save.'
346
+ ].join('\n');
347
+ };
348
+ // the guide, a blank line, then the editor's latest command list, as it came; even while the tab is away
349
+ const listCommands = (hash) => {
350
+ const { error, session } = readSession(hash);
351
+ if (error) {
352
+ return error;
353
+ }
354
+ const commands = JSON.stringify(session.commands);
355
+ markConnected(session);
356
+ return { content: [{ text: `${GUIDE}\n\n${commands}`, type: 'text' }], isError: false };
357
+ };
358
+ // the socket closed or found silent: every call on its way hears so, and the 2-minute grace starts
359
+ const loseSocket = (session) => {
360
+ session.socket = null;
361
+ settleCalls(session, name => {
362
+ return `The editor's tab disconnected while ${name} ran: it may have run or not. Once the tab is back, take a screenshot to see.`;
363
+ });
364
+ clearTimeout(session.graceTimer);
365
+ session.graceTimer = scheduleEnd({ hash: session.hash, ms: RECONNECT_GRACE, reason: 'TAB_CLOSED' });
366
+ };
367
+ // an agent's first call: the editor shows "Agent connected" from then on
368
+ const markConnected = (session) => {
369
+ if (session.connected) {
370
+ return;
371
+ }
372
+ session.connected = true;
373
+ if (session.socket) {
374
+ sendMessage(session.socket, { type: 'AGENT_CONNECTED' });
375
+ }
376
+ };
377
+ const mcpTools = (origin) => {
378
+ const link = {
379
+ description: `The link the person copied from the editor, as they pasted it (like ${origin}/link#…).`,
380
+ type: 'string'
381
+ };
382
+ return [
383
+ {
384
+ description: "Lists the commands of the person's Embed Editor, open in their browser tab, by the link they copied from it: each command's name, what it does, and its input as JSON Schema. Call it first, then run_command for each command.",
385
+ inputSchema: { properties: { link }, required: ['link'], type: 'object' },
386
+ name: 'list_commands'
387
+ },
388
+ {
389
+ description: "Runs one of the editor's commands, as list_commands gives them: its name, and its input as the command's inputSchema says.",
390
+ inputSchema: {
391
+ properties: {
392
+ input: { description: "The command's input, as its inputSchema in list_commands says ({} for a command that takes none).", type: 'object' },
393
+ link,
394
+ name: { description: "The command's name, as list_commands gives it (like read_screenshot).", type: 'string' }
395
+ },
396
+ required: ['link', 'name', 'input'],
397
+ type: 'object'
398
+ },
399
+ name: 'run_command'
400
+ }
401
+ ];
402
+ };
403
+ const parseJson = (text) => {
404
+ try {
405
+ return JSON.parse(text);
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ };
411
+ // the body of POST /sessions/end: its token and whether the tab is closing (`closed`, the beacon's), or null
412
+ const readEndBody = (text) => {
413
+ const body = parseJson(text);
414
+ const token = readToken(body?.token);
415
+ return token ? { closed: body?.closed === true, token } : null;
416
+ };
417
+ // one frame from the editor: ping, its command list, or a call's result. A call belongs to its session, so its result
418
+ // counts from whichever of the session's sockets brings it; only the open socket's ping and commands count
419
+ const readMessage = ({ current, session, text, ws }) => {
420
+ if (text === 'ping') {
421
+ if (current) {
422
+ session.heardAt = Date.now();
423
+ }
424
+ ws.send('pong');
425
+ return;
426
+ }
427
+ const message = parseJson(text);
428
+ if (message?.type === 'result') {
429
+ const call = session.calls.get(message.id);
430
+ call?.settle({ content: message.content, isError: message.isError });
431
+ return;
432
+ }
433
+ if (current && message?.type === 'commands' && Array.isArray(message.commands)) {
434
+ session.commands = message.commands;
435
+ }
436
+ };
437
+ // the live session a call's link names, or why there is none: never made (or deleted an hour after its end), or ended
438
+ const readSession = (hash) => {
439
+ const session = sessions.get(hash);
440
+ if (!session) {
441
+ return { error: errorResult(NOT_VALID), session: null };
442
+ }
443
+ if (session.endedReason) {
444
+ return { error: errorResult(ENDED_TEXTS[session.endedReason]), session: null };
445
+ }
446
+ return { error: null, session };
447
+ };
448
+ // the token in a link: what follows the last # (or the whole text, for a token pasted alone), trimmed
449
+ const readToken = (link) => {
450
+ if (typeof link !== 'string') {
451
+ return null;
452
+ }
453
+ const start = link.lastIndexOf('#') + 1;
454
+ const token = link.slice(start).trim();
455
+ return TOKEN.test(token) ? token : null;
456
+ };
457
+ // the origin a request's links and MCP instructions name: the settings' publicOrigin, else the request's own
458
+ const relayOrigin = ({ publicOrigin, url }) => {
459
+ const { origin } = new URL(url);
460
+ return publicOrigin ?? origin;
461
+ };
462
+ // sends a CALL when the editor's socket is open and not silent, and answers its result unchanged. The name goes to the
463
+ // editor unchecked: it runs only a command it offered
464
+ const runCommand = async ({ args, hash }) => {
465
+ const { name } = args;
466
+ const input = args.input ?? {};
467
+ if (typeof name !== 'string') {
468
+ return errorResult(NO_NAME);
469
+ }
470
+ const { error, session } = readSession(hash);
471
+ if (error) {
472
+ return error;
473
+ }
474
+ markConnected(session);
475
+ // a socket with no ping for over 25 s is a tab away: closed with 4002, and the grace starts
476
+ if (session.socket && Date.now() - session.heardAt > SOCKET_STALE) {
477
+ session.socket.close(CLOSE_SILENT, 'SILENT');
478
+ loseSocket(session);
479
+ }
480
+ const { socket } = session;
481
+ if (!socket) {
482
+ return errorResult(NOT_CONNECTED);
483
+ }
484
+ const startedAt = Date.now();
485
+ const res = await callEditor({ input, name, session, socket });
486
+ const ms = Date.now() - startedAt;
487
+ const content = JSON.stringify(res.content);
488
+ const bytes = encoder.encode(content);
489
+ // the log names the command, its time and its size, never the token or what the call carried
490
+ console.log(`run_command ${name}: ${ms} ms, ${bytes.length} bytes${res.isError ? ', an error' : ''}`);
491
+ return res;
492
+ };
493
+ // a timer that ends the hash's session for `reason` after `ms`
494
+ const scheduleEnd = ({ hash, ms, reason }) => {
495
+ return setTimeout(() => {
496
+ const session = sessions.get(hash);
497
+ if (session) {
498
+ endSession(session, reason);
499
+ }
500
+ }, ms);
501
+ };
502
+ // the relay's messages to the editor, as JSON (pong, the one plain word, is sent as it is)
503
+ const sendMessage = (socket, message) => {
504
+ const text = JSON.stringify(message);
505
+ socket.send(text);
506
+ };
507
+ // answers every call on its way, each with its own sentence
508
+ const settleCalls = (session, textFor) => {
509
+ session.calls.forEach(call => {
510
+ const text = textFor(call.name);
511
+ const res = errorResult(text);
512
+ call.settle(res);
513
+ });
514
+ };
515
+ // the socket's events. The session holds the socket as it opened, and every later event of the connection is matched by
516
+ // that one: a runtime may hand each event a context of its own
517
+ const socketEvents = (hash) => {
518
+ let opened = null;
519
+ return {
520
+ onClose: () => {
521
+ const session = sessions.get(hash);
522
+ if (session && opened && session.socket === opened) {
523
+ loseSocket(session);
524
+ }
525
+ },
526
+ onMessage: (e, ws) => {
527
+ const session = sessions.get(hash);
528
+ if (session && opened) {
529
+ readMessage({ current: session.socket === opened, session, text: `${e.data}`, ws });
530
+ }
531
+ },
532
+ onOpen: (e, ws) => {
533
+ opened = acceptSocket({ hash, ws }) ? ws : null;
534
+ }
535
+ };
536
+ };
537
+ // the token of the editor's socket: an upgrade, with the subprotocols ee-agent and the token, in that order, or null
538
+ const socketToken = (headers) => {
539
+ const upgrade = headers.get('upgrade') ?? '';
540
+ const protocols = headers.get('sec-websocket-protocol') ?? '';
541
+ const parts = protocols.split(',');
542
+ const [protocol, token = ''] = parts.map(item => {
543
+ return item.trim();
544
+ });
545
+ return upgrade.toLowerCase() === 'websocket' && protocol === PROTOCOL && TOKEN.test(token) ? token : null;
546
+ };
547
+ export default { createRelay };
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
- "name": "@embededitor/relay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@embededitor/relay",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "Embed Editor's agent link relay, to run on your own server: your users' AI agent edits the photo open in your editor, through your own address.",
6
+ "license": "MIT",
7
+ "homepage": "https://docs.embededitor.com/agent-link/relay",
8
+ "keywords": [
9
+ "embed-editor",
10
+ "photo-editor",
11
+ "mcp",
12
+ "ai-agent",
13
+ "relay",
14
+ "hono"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "CHANGELOG.md",
28
+ "LICENSE.md",
29
+ "README.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
36
+ "lint": "eslint . --fix",
37
+ "prepack": "npm run build",
38
+ "release": "vitest --run && npm publish --@embededitor:registry=https://registry.npmjs.org/",
39
+ "test": "vitest --run",
40
+ "typecheck": "tsc"
41
+ },
42
+ "dependencies": {
43
+ "@hono/node-server": "^2.1.6",
44
+ "@modelcontextprotocol/sdk": "1.32.2",
45
+ "@types/ws": "^8.18.2",
46
+ "hono": "^4.13.17",
47
+ "ws": "^8.21.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^24.0.10",
51
+ "typescript": "^6.0.3",
52
+ "vitest": "^5.0.2"
53
+ }
54
+ }