Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/core/http-devserver.ts
3557 views
1
/*
2
* http-devserver.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { LogRecord } from "../deno_ral/log.ts";
8
import { join } from "../deno_ral/path.ts";
9
import * as ld from "./lodash.ts";
10
11
import { renderEjs } from "./ejs.ts";
12
import { httpContentResponse, maybeDisplaySocketError } from "./http.ts";
13
import { FileResponse } from "./http-types.ts";
14
import { LogEventsHandler } from "./log.ts";
15
import { resourcePath } from "./resources.ts";
16
import { isRStudioPreview, isRStudioServer } from "./platform.ts";
17
import { kTextHtml } from "./mime.ts";
18
19
export interface HttpDevServer {
20
handle: (req: Request) => boolean;
21
request: (req: Request) => Promise<Response | undefined>;
22
injectClient: (
23
req: Request,
24
file: Uint8Array,
25
inputFile?: string,
26
contentType?: string,
27
) => FileResponse;
28
clientHtml: (
29
req: Request,
30
inputFile?: string,
31
) => string;
32
reloadClients: (reloadTarget?: string) => Promise<void>;
33
hasClients: () => boolean;
34
}
35
36
export function httpDevServer(
37
timeout: number,
38
isRendering: () => boolean,
39
stopServer: VoidFunction,
40
isPresentation?: boolean,
41
): HttpDevServer {
42
// track clients
43
interface Client {
44
socket: WebSocket;
45
}
46
const clients: Client[] = [];
47
48
// socket close handler that waits 2 seconds (debounced) and then
49
// stops the server is there are no more clients and we are not in the
50
// middle of a render. don't do this for rstudio b/c rstudio manages
51
// the lifetime of quarto preview directly
52
const hasClients = () => {
53
return isRendering() ||
54
!!clients.find((client) => client.socket.readyState !== WebSocket.CLOSED);
55
};
56
let onSocketClose: VoidFunction | undefined;
57
if ((timeout > 0) && !isRStudioPreview()) {
58
onSocketClose = ld.debounce(() => {
59
if (!hasClients()) {
60
stopServer();
61
}
62
}, timeout * 1000);
63
}
64
65
const broadcast = (msg: string) => {
66
for (let i = clients.length - 1; i >= 0; i--) {
67
const socket = clients[i].socket;
68
try {
69
socket.send(msg);
70
} catch (_e) {
71
// we don't want to recurse so we ignore errors here
72
}
73
}
74
};
75
76
// stream render events to clients
77
HttpDevServerRenderMonitor.monitor({
78
onRenderStart: (lastRenderTime?: number) => {
79
broadcast(`render:start:${lastRenderTime || 0}`);
80
},
81
onRenderStop: (success: boolean) => {
82
broadcast(`render:stop:${success}`);
83
},
84
});
85
86
// stream log events to clients
87
LogEventsHandler.onLog(async (logRecord: LogRecord, msg: string) => {
88
broadcast(
89
"log:" + JSON.stringify({
90
...logRecord,
91
msgFormatted: msg,
92
}),
93
);
94
});
95
96
let injectClientInitialized = false;
97
let iframeURL: URL | undefined;
98
const getiFrameURL = (req: Request) => {
99
if (!injectClientInitialized) {
100
iframeURL = viewerIFrameURL(req);
101
injectClientInitialized = true;
102
}
103
return iframeURL;
104
};
105
106
const kQuartoPreviewJs = "quarto-preview.js";
107
return {
108
handle: (req: Request) => {
109
// handle requests for quarto-preview.js
110
const url = new URL(req.url);
111
if (url.pathname.endsWith(kQuartoPreviewJs)) {
112
return true;
113
}
114
115
// handle websocket upgrade requests
116
if (req.headers.get("upgrade") === "websocket") {
117
return true;
118
}
119
120
return false;
121
},
122
request: async (req: Request) => {
123
const url = new URL(req.url);
124
if (url.pathname.endsWith(kQuartoPreviewJs)) {
125
const path = resourcePath(join("preview", kQuartoPreviewJs));
126
const contents = await Deno.readFile(path);
127
return httpContentResponse(contents, "text/javascript");
128
} else {
129
try {
130
const { socket, response } = Deno.upgradeWebSocket(req);
131
const client: Client = { socket };
132
socket.onmessage = (ev: MessageEvent<string>) => {
133
if (ev.data === "stop") {
134
stopServer();
135
}
136
};
137
if (onSocketClose) {
138
socket.onclose = onSocketClose;
139
}
140
clients.push(client);
141
return Promise.resolve(response);
142
} catch (e) {
143
maybeDisplaySocketError(e);
144
return Promise.resolve(undefined);
145
}
146
}
147
},
148
clientHtml: (
149
req: Request,
150
inputFile?: string,
151
): string => {
152
const script = devServerClientScript(
153
inputFile,
154
isPresentation,
155
getiFrameURL(req),
156
);
157
return script;
158
},
159
injectClient: (
160
req: Request,
161
file: Uint8Array,
162
inputFile?: string,
163
contentType?: string,
164
): FileResponse => {
165
const script = devServerClientScript(
166
inputFile,
167
isPresentation,
168
getiFrameURL(req),
169
);
170
const scriptContents = new TextEncoder().encode("\n" + script);
171
const fileWithScript = new Uint8Array(
172
file.length + scriptContents.length,
173
);
174
fileWithScript.set(file);
175
fileWithScript.set(scriptContents, file.length);
176
return {
177
contentType: contentType || kTextHtml,
178
body: fileWithScript,
179
};
180
},
181
182
reloadClients: async (reloadTarget = "") => {
183
for (let i = clients.length - 1; i >= 0; i--) {
184
const socket = clients[i].socket;
185
try {
186
const message = "reload";
187
await socket.send(`${message}${reloadTarget}`);
188
} catch (e) {
189
maybeDisplaySocketError(e);
190
} finally {
191
if (!socket.CLOSED && !socket.CLOSING) {
192
try {
193
socket.close();
194
} catch (e) {
195
maybeDisplaySocketError(e);
196
}
197
}
198
clients.splice(i, 1);
199
}
200
}
201
},
202
203
hasClients,
204
};
205
}
206
207
export interface RenderMonitor {
208
onRenderStart: (lastRenderTime?: number) => void;
209
onRenderStop: (success: boolean) => void;
210
}
211
212
export class HttpDevServerRenderMonitor {
213
public static onRenderStart() {
214
this.renderStart_ = Date.now();
215
this.handlers_.forEach((handler) =>
216
handler.onRenderStart(this.lastRenderTime_)
217
);
218
}
219
220
public static onRenderStop(success: boolean) {
221
if (this.renderStart_) {
222
this.lastRenderTime_ = Date.now() - this.renderStart_;
223
this.renderStart_ = undefined;
224
}
225
this.handlers_.forEach((handler) => handler.onRenderStop(success));
226
}
227
228
public static monitor(handler: RenderMonitor) {
229
this.handlers_.push(handler);
230
}
231
232
private static handlers_ = new Array<RenderMonitor>();
233
234
private static renderStart_: number | undefined;
235
private static lastRenderTime_: number | undefined;
236
}
237
238
function devServerClientScript(
239
inputFile?: string,
240
isPresentation?: boolean,
241
iframeURL?: URL,
242
): string {
243
const options = {
244
origin: iframeURL ? devserverOrigin(iframeURL) : null,
245
search: iframeURL ? iframeURL.search : null,
246
inputFile: inputFile || null,
247
isPresentation: !!isPresentation,
248
};
249
return renderEjs(resourcePath(`preview/quarto-preview.html`), options);
250
}
251
252
function devserverOrigin(iframeURL: URL) {
253
if (isRStudioServer()) {
254
return iframeURL.searchParams.get("host") || iframeURL.origin;
255
} else {
256
return iframeURL.origin;
257
}
258
}
259
260
export function viewerIFrameURL(req: Request) {
261
for (const url of [req.url, req.referrer]) {
262
const isViewer = url && (
263
url.includes("capabilities=") || // rstudio viewer
264
url.includes("vscodeBrowserReqId=") || // vscode simple browser
265
url.includes("quartoPreviewReqId=") // generic embedded browser
266
);
267
268
if (isViewer) {
269
return new URL(url);
270
}
271
}
272
}
273
274