-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathedge.js
More file actions
503 lines (449 loc) · 18.4 KB
/
edge.js
File metadata and controls
503 lines (449 loc) · 18.4 KB
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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import * as Y from 'yjs';
import { yDocToProsemirror } from 'y-prosemirror';
import { invalidateFromAdmin, setupWSConnection } from './shareddoc.js';
import { aem2doc } from './collab.js';
import { getSchema } from './schema.js';
/**
* This is the Edge Worker, built using Durable Objects!
* ===============================
* Required Environment
* ===============================
*
* This worker, when deployed, must be configured with an environment binding:
* - rooms: A Durable Object namespace binding mapped to the DocRoom class.
*/
/**
* A little utility function that can wrap an HTTP request handler in a
* try/catch and return errors to the client. You probably wouldn't want to use this in production
* code but it is convenient when debugging and iterating.
*
* @param {Request} request
* @param {Env} env
* @param {Fetcher} handler
* @returns {Promise<Response>}
*/
export async function handleErrors(request, env, handler) {
try {
return await handler(request, env);
} catch (err) {
console.log('Error handling request for %s:', request.url, err);
const msg = String(env.RETURN_STACK_TRACES) === 'true'
? JSON.stringify({ error: err.stack })
: 'Internal Server Error';
if (request.headers.get('Upgrade') === 'websocket') {
// Annoyingly, if we return an HTTP error in response to a WebSocket request,
// Chrome devtools won't show us the response body! So... let's send a WebSocket
// response with an error frame instead.
// eslint-disable-next-line no-undef
const pair = new WebSocketPair();
pair[1].accept();
pair[1].send(msg);
pair[1].close(1011, 'Uncaught exception during session setup');
return new Response(null, { status: 101, webSocket: pair[0] });
}
return new Response(msg, { status: 500 });
}
}
/**
* Admin APIs are forwarded to the durable object. They need the doc name as a query
* parameter on the url.
* @param {string} api
* @param {URL} url
* @param {Request} request
* @param {Env} env
*/
async function adminAPI(api, url, request, env) {
const doc = url.searchParams.get('doc');
if (!doc) {
return new Response('Bad', { status: 400 });
}
// check if shared token is configured and validate
if (env.COLLAB_SHARED_SECRET) {
if (request.headers.get('authorization') !== `token ${env.COLLAB_SHARED_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
}
const id = env.rooms.idFromName(doc);
// eslint-disable-next-line no-console
console.log('[worker] - Admin API', doc);
const roomObject = env.rooms.get(id);
// TODO: check for roomObject === null ?
// note, that we cannot call DocRoom.handleApiCall directly w/o enabling
// RPC on on the durable objects.
const apiUrl = new URL(doc);
apiUrl.searchParams.set('api', api);
return roomObject.fetch(new Request(apiUrl));
}
/**
* A simple Ping API to check that the worker responds.
* @param {Env} env
*/
function ping(env) {
const adminsb = env.daadmin !== undefined ? '"da-admin"' : '';
const json = `{
"status": "ok",
"service_bindings": [${adminsb}]
}
`;
return new Response(json, { status: 200 });
}
/**
* CORS headers for the convert API.
* Allows cross-origin requests from da.live frontends.
*/
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
/**
* Handle CORS preflight requests.
* @returns {Response}
*/
function handleCorsPreFlight() {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
/**
* Convert AEM HTML to ProseMirror JSON without creating a persistent document.
* This is a stateless conversion API for version preview and template insertion.
* @param {Request} request - The request object containing HTML in the body
* @returns {Promise<Response>} - JSON response with prosemirror doc and metadata
*/
export async function handleConvert(request) {
if (request.method === 'OPTIONS') {
return handleCorsPreFlight();
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method Not Allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
});
}
try {
const body = await request.json();
const { html } = body;
if (!html) {
return new Response(JSON.stringify({ error: 'Missing html parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
});
}
// Create a temporary YDoc and convert HTML to it
const ydoc = new Y.Doc();
aem2doc(html, ydoc);
// Convert YDoc to ProseMirror JSON
const schema = getSchema();
const pmDoc = yDocToProsemirror(schema, ydoc);
// Get daMetadata from the yMap
const mdMap = ydoc.getMap('daMetadata');
const daMetadata = {};
mdMap.forEach((value, key) => {
daMetadata[key] = value;
});
// Clean up the temporary ydoc
ydoc.destroy();
return new Response(JSON.stringify({
prosemirror: pmDoc.toJSON(),
daMetadata,
}), {
status: 200,
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
});
} catch (err) {
// eslint-disable-next-line no-console
console.error('[worker] Convert error:', err);
return new Response(JSON.stringify({ error: 'Conversion failed', details: err.message }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
});
}
}
/** Handle the API calls. Supported API calls right now are:
* /ping - returns a simple JSON response to check that the worker is up.
* /syncadmin - sync the doc state with the state of da-admin. Any internal state
* for this document in the worker is cleared.
* /deleteadmin - the document is deleted and should be removed from the worker internal state.
* /convert - stateless conversion of AEM HTML to ProseMirror JSON (for version preview).
* @param {URL} url - The request url
* @param {Request} request - The request object
* @param {Env} env - The worker environment
* @return {Promise<Response>}
*/
async function handleApiCall(url, request, env) {
switch (url.pathname) {
case '/api/v1/ping':
return ping(env);
case '/api/v1/syncadmin':
return adminAPI('syncAdmin', url, request, env);
case '/api/v1/deleteadmin':
return adminAPI('deleteAdmin', url, request, env);
case '/api/v1/convert':
return handleConvert(request);
default:
return new Response('Bad Request', { status: 400 });
}
}
/**
* This is where the requests for the worker come in. They can either be pure API requests or
* requests to set up a session with a Durable Object through a Yjs WebSocket.
*
* @param request
* @param env
* @returns {Promise<*|Response>}
*/
export async function handleApiRequest(request, env) {
let timingDaAdminHeadDuration;
const timingStartTime = Date.now();
// We've received a pure API request - handle it and return.
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) {
return handleApiCall(url, request, env);
}
const protocols = request.headers.get('sec-websocket-protocol')?.split(',');
const token = protocols?.find((hdr) => hdr !== 'yjs')?.trim();
let auth = '';
if (token) {
auth = `Bearer ${token}`;
} else {
auth = url.searchParams.get('Authorization');
}
// We need to massage the path somewhat because on connections from localhost safari sends
// a path with only one slash for some reason.
let docName = request.url.substring(new URL(request.url).origin.length + 1)
.replace('https:/admin.da.page', 'https://admin.da.page')
.replace('https:/admin.da.live', 'https://admin.da.live')
.replace('http:/localhost', 'http://localhost');
if (docName.indexOf('?') > 0) {
docName = docName.substring(0, docName.indexOf('?'));
}
// Make sure we only work with da.live, da.page or localhost
if (!docName.startsWith('https://admin.da.live/')
&& !docName.startsWith('https://admin.da.page/')
&& !docName.startsWith('https://stage-admin.da.live/')
&& !docName.startsWith('http://localhost:')) {
return new Response('unable to get resource', { status: 404 });
}
// Check if we have the authorization for the room (this is a poor man's solution as right now
// only da-admin knows).
let authActions;
try {
const opts = { method: 'HEAD' };
if (auth) {
opts.headers = new Headers({ Authorization: auth });
}
const timingBeforeDaAdminHead = Date.now();
const initialReq = await env.daadmin.fetch(docName, opts);
timingDaAdminHeadDuration = Date.now() - timingBeforeDaAdminHead;
if (!initialReq.ok) {
// eslint-disable-next-line no-console
console.log(`[worker] Unable to get resource ${docName}: ${initialReq.status} - ${initialReq.statusText}`);
return new Response('unable to get resource', { status: initialReq.status });
}
// this seems to be required by CloudFlare to consider the request as completed
await initialReq.text();
const daActions = initialReq.headers.get('X-da-actions') ?? '';
[, authActions] = daActions.split('=');
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[worker] Unable to handle API request ${docName}`, err);
return new Response('unable to get resource', { status: 500 });
}
try {
const timingBeforeDocRoomGet = Date.now();
// Each Durable Object has a 256-bit unique ID. Route the request based on the path.
const id = env.rooms.idFromName(docName);
// Get the Durable Object stub for this room! The stub is a client object that can be used
// to send messages to the remote Durable Object instance. The stub is returned immediately;
// there is no need to await it. This is important because you would not want to wait for
// a network round trip before you could start sending requests. Since Durable Objects are
// created on-demand when the ID is first used, there's nothing to wait for anyway; we know
// an object will be available somewhere to receive our requests.
const roomObject = env.rooms.get(id);
const timingDocRoomGetDuration = Date.now() - timingBeforeDocRoomGet;
// eslint-disable-next-line no-console
console.log('[worker] Fecthing', docName);
const headers = [...request.headers,
['X-collab-room', docName],
['X-timing-start', timingStartTime],
['X-timing-da-admin-head-duration', timingDaAdminHeadDuration],
['X-timing-docroom-get-duration', timingDocRoomGetDuration],
['X-auth-actions', authActions],
];
if (auth) {
headers.push(['Authorization', auth]);
}
const req = new Request(new URL(docName), { headers });
// Send the request to the Durable Object. The `fetch()` method of a Durable Object stub has the
// same signature as the global `fetch()` function, but the request is always sent to the
// object, regardless of the hostname in the request's URL.
return await roomObject.fetch(req);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[worker] Error fetching the doc from the room ${docName}`, err);
return new Response('unable to get resource', { status: 500 });
}
}
// In modules-syntax workers, we use `export default` to export our script's main event handlers.
// This is the main entry point for the worker.
export default {
/**
* @param {Request} request
* @param {Env} env
* @returns {Promise<Response>}
*/
async fetch(request, env) {
return handleErrors(request, env, handleApiRequest);
},
};
// =======================================================================================
// The Durable Object Class
/**
* Implements a Durable Object that coordinates an individual doc room. Participants
* connect to the room using WebSockets, and the room broadcasts messages from each participant
* to all others.
*
* @tpye {Fetcher}
*/
export class DocRoom {
constructor(controller, env) {
// `controller.storage` provides access to our durable storage. It provides a simple KV
// get()/put() interface.
this.storage = controller?.storage;
// `env` is our environment bindings (discussed earlier).
this.env = env;
this.id = controller?.id?.toString() || `no-controller-${new Date().getTime()}`;
}
/**
* Handle the API calls. Supported API calls right now are to sync the doc with the da-admin
* state or to indicate that the document has been deleted from da-admin.
* The implementation of these two is currently identical.
* @param {string} api
* @param {string} docName
* @param {Request} request
* @returns {Promise<*>}
*/
// eslint-disable-next-line class-methods-use-this,no-unused-vars
async handleApiCall(api, docName, request) {
switch (api) {
case 'deleteAdmin':
if (await invalidateFromAdmin(docName)) {
return new Response(null, { status: 204 });
} else {
return new Response('Not Found', { status: 404 });
}
case 'syncAdmin':
if (await invalidateFromAdmin(docName)) {
return new Response('OK', { status: 200 });
} else {
return new Response('Not Found', { status: 404 });
}
default:
return new Response('Invalid API', { status: 400 });
}
}
// Isolated for testing
static newWebSocketPair() {
// eslint-disable-next-line no-undef
return new WebSocketPair();
}
/**
* The system will call fetch() whenever an HTTP request is sent to this Object. Such requests
* can only be sent from other Worker code, such as the code above; these requests don't come
* directly from the internet. In the future, we will support other formats than HTTP for these
* communications, but we started with HTTP for its familiarity.
*
* Note that strangely enough in a unit testing env returning a Response with status 101 isn't
* allowed by the runtime, so we can set an alternative 'success' code here for testing.
* @param {Request} request
* @param {object} _opts
* @param {Number} successCode
* @returns {Promise<Response>}
*/
async fetch(request, _opts, successCode = 101) {
try {
// If it's a pure API call then handle it and return.
const url = new URL(request.url);
const api = url.searchParams.get('api');
if (api) {
url.searchParams.delete('api');
return this.handleApiCall(api, url.href, request);
}
// If we get here, we're expecting this to be a WebSocket request.
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('expected websocket', { status: 400 });
}
const auth = request.headers.get('Authorization');
const authActions = request.headers.get('X-auth-actions') ?? '';
const docName = request.headers.get('X-collab-room');
if (!docName) {
return new Response('expected docName', { status: 400 });
}
const timingBeforeSetupWebsocket = Date.now();
// To accept the WebSocket request, we create a WebSocketPair (which is like a socketpair,
// i.e. two WebSockets that talk to each other), we return one end of the pair in the
// response, and we operate on the other end. Note that this API is not part of the
// Fetch API standard; unfortunately, the Fetch API / Service Workers specs do not define
// any way to act as a WebSocket server today.
const pair = DocRoom.newWebSocketPair();
// We're going to take pair[1] as our end, and return pair[0] to the client.
const timingData = await this.handleSession(pair[1], docName, auth, authActions);
const timingSetupWebSocketDuration = Date.now() - timingBeforeSetupWebsocket;
const reqHeaders = request.headers;
const respheaders = new Headers({
'X-1-timing-da-admin-head-duration': reqHeaders.get('X-timing-da-admin-head-duration'),
'X-2-timing-docroom-get-duration': reqHeaders.get('X-timing-docroom-get-duration'),
'X-4-timing-da-admin-get-duration': timingData.get('timingDaAdminGetDuration'),
'X-5-timing-read-state-duration': timingData.get('timingReadStateDuration'),
'X-7-timing-setup-websocket-duration': timingSetupWebSocketDuration,
'X-9-timing-full-duration': Date.now() - reqHeaders.get('X-timing-start'),
});
const protocols = reqHeaders.get('sec-websocket-protocol')?.split(',');
if (protocols?.includes('yjs')) {
respheaders.set('sec-websocket-protocol', 'yjs');
}
// Now we return the other end of the pair to the client.
return new Response(null, { status: successCode, headers: respheaders, webSocket: pair[0] });
} catch (err) {
// eslint-disable-next-line no-console
console.error('[docroom] Error while fetching', err);
return new Response('Internal Server Error', { status: 500 });
}
}
/**
* Implements our WebSocket-based protocol.
* @param {WebSocket} webSocket - The WebSocket connection to the client
* @param {string} docName - The document name
* @param {string} auth - The authorization header
* @param {string} authActions
*/
async handleSession(webSocket, docName, auth, authActions) {
// Accept our end of the WebSocket. This tells the runtime that we'll be terminating the
// WebSocket in JavaScript, not sending it elsewhere.
webSocket.accept();
// eslint-disable-next-line no-param-reassign
webSocket.auth = auth;
if (!authActions.split(',').includes('write')) {
// eslint-disable-next-line no-param-reassign
webSocket.readOnly = true;
}
// eslint-disable-next-line no-console
console.log(`[docroom] Setting up WSConnection for ${docName} with auth(${webSocket.auth
? webSocket.auth.substring(0, webSocket.auth.indexOf(' ')) : 'none'})`);
const timingData = await setupWSConnection(webSocket, docName, this.env, this.storage);
return timingData;
}
}