Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/project/serve/watch.ts
6463 views
1
/*
2
* watch.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { join, relative } from "../../deno_ral/path.ts";
8
import { existsSync } from "../../deno_ral/fs.ts";
9
10
import * as ld from "../../core/lodash.ts";
11
12
import { normalizePath, pathWithForwardSlashes } from "../../core/path.ts";
13
import { md5HashAsync, md5HashSync } from "../../core/hash.ts";
14
15
import { logError } from "../../core/log.ts";
16
import { isRevealjsOutput } from "../../config/format.ts";
17
18
import {
19
kProjectLibDir,
20
kProjectWatchInputs,
21
ProjectContext,
22
} from "../../project/types.ts";
23
import { projectOutputDir } from "../../project/project-shared.ts";
24
import { projectContext } from "../../project/project-context.ts";
25
26
import { ProjectWatcher, ServeOptions } from "./types.ts";
27
import { httpDevServer } from "../../core/http-devserver.ts";
28
import { RenderOptions } from "../../command/render/types.ts";
29
import { renderProject } from "../../command/render/project.ts";
30
import { render } from "../../command/render/render-shared.ts";
31
import { renderServices } from "../../command/render/render-services.ts";
32
33
import { isRStudio } from "../../core/platform.ts";
34
import { inputTargetIndexForOutputFile } from "../../project/project-index.ts";
35
import { isPdfContent } from "../../core/mime.ts";
36
import { ServeRenderManager } from "./render.ts";
37
import { existsSync1 } from "../../core/file.ts";
38
import { watchForFileChanges } from "../../core/watch.ts";
39
import { extensionFilesFromDirs } from "../../extension/extension.ts";
40
import { notebookContext } from "../../render/notebook/notebook-context.ts";
41
42
interface WatchChanges {
43
config: boolean;
44
output: boolean;
45
reloadTarget?: string;
46
}
47
48
export function watchProject(
49
project: ProjectContext,
50
extensionDirs: string[],
51
resourceFiles: string[],
52
renderOptions: RenderOptions,
53
pandocArgs: string[],
54
options: ServeOptions,
55
renderingOnReload: boolean,
56
renderManager: ServeRenderManager,
57
stopServer: VoidFunction,
58
): Promise<ProjectWatcher> {
59
const nbContext = notebookContext();
60
const flags = renderOptions.flags;
61
// helper to refresh project config
62
const refreshProjectConfig = async () => {
63
project.cleanup();
64
project =
65
(await projectContext(project.dir, nbContext, renderOptions, false))!;
66
};
67
68
// proj dir
69
const projDir = normalizePath(project.dir);
70
const projDirHidden = projDir + "/.";
71
72
// output dir
73
const outputDir = projectOutputDir(project);
74
75
// lib dir
76
const libDirConfig = project.config?.project[kProjectLibDir];
77
const libDirSource = libDirConfig
78
? join(project.dir, libDirConfig)
79
: undefined;
80
81
// is this a resource file?
82
const isResourceFile = (path: string) => {
83
// exclude libdir
84
if (libDirSource && path.startsWith(libDirSource)) {
85
return false;
86
} else {
87
// check project resources and resources derived from
88
// indvidual files
89
return project.files.resources?.includes(path) ||
90
resourceFiles.includes(path);
91
}
92
};
93
94
// is this an extension file
95
const extensionFiles = extensionFilesFromDirs(extensionDirs);
96
const isExtensionFile = (path: string) => {
97
return extensionFiles.includes(path);
98
};
99
100
// is this an input file?
101
const isInputFile = (path: string) => {
102
return project.files.input.includes(path);
103
};
104
105
// track rendered inputs and outputs so we don't double render if the file notifications are chatty
106
const rendered = new Map<string, string>();
107
108
// handle a watch event (return true if a reload should occur)
109
const handleWatchEvent = async (
110
event: Deno.FsEvent,
111
): Promise<WatchChanges | undefined> => {
112
try {
113
const paths = ld.uniq(
114
event.paths
115
// filter out paths in hidden folders (e.g. .quarto, .git, .Rproj.user)
116
.filter((path) => !path.startsWith(projDirHidden))
117
// filter out the output dir if its distinct from the project dir
118
.filter((path) =>
119
outputDir === project.dir || !path.startsWith(outputDir)
120
),
121
);
122
123
// return if there are no paths
124
if (paths.length === 0) {
125
return;
126
}
127
128
if (["modify", "create"].includes(event.kind)) {
129
// render changed input files (if we are watching). then
130
// arrange for client reload
131
if (options[kProjectWatchInputs]) {
132
// get inputs (filter by whether the last time we rendered
133
// this input had the exact same content hash)
134
const inputs = paths.filter(isInputFile).filter(existsSync1).filter(
135
(input: string) => {
136
return !rendered.has(input) ||
137
rendered.get(input) !==
138
md5HashSync(Deno.readTextFileSync(input));
139
},
140
);
141
if (inputs.length) {
142
// render
143
const services = renderServices(nbContext);
144
try {
145
const result = await renderManager.submitRender(() => {
146
if (inputs.length > 1) {
147
return renderProject(
148
project!,
149
{
150
services,
151
progress: true,
152
flags,
153
pandocArgs,
154
previewServer: true,
155
},
156
inputs,
157
);
158
} else {
159
return render(inputs[0], {
160
services,
161
flags,
162
pandocArgs: pandocArgs,
163
previewServer: true,
164
});
165
}
166
});
167
168
if (result.error) {
169
result.context.cleanup();
170
renderManager.onRenderError(result.error);
171
return undefined;
172
}
173
// record rendered hash
174
for (const input of inputs.filter(existsSync1)) {
175
rendered.set(
176
input,
177
await md5HashAsync(Deno.readTextFileSync(input)),
178
);
179
}
180
renderManager.onRenderResult(
181
result,
182
extensionDirs,
183
resourceFiles,
184
project!,
185
);
186
187
// Filter out supplmental files (e.g. files that were injected as supplements)
188
// to the render. Instead, we should return the first non-supplemental file.
189
// Example of supplemental file is a user rendering a post that appears in a listing
190
// - the listing will be added as a supplement since changes in the post may change the
191
// listing itself
192
const nonSupplementalFiles = result.files.filter(
193
(renderResultFile) => {
194
return !renderResultFile.supplemental;
195
},
196
);
197
result.context.cleanup();
198
199
return {
200
config: false,
201
output: true,
202
reloadTarget: (nonSupplementalFiles.length &&
203
!isPdfContent(nonSupplementalFiles[0].file))
204
? join(outputDir, nonSupplementalFiles[0].file)
205
: undefined,
206
};
207
} finally {
208
services.cleanup();
209
}
210
}
211
}
212
213
const configFile = paths.some((path: string) =>
214
(project.files.config || []).includes(path)
215
);
216
const inputFileRemoved = project.files.input.some((file) =>
217
!existsSync(file)
218
);
219
const configResourceFile = paths.some((path: string) =>
220
(project.files.configResources || []).includes(path) &&
221
!project.files.input.includes(path)
222
);
223
const resourceFile = paths.some(isResourceFile);
224
const extensionFile = paths.some(isExtensionFile);
225
226
const reload = configFile || configResourceFile ||
227
resourceFile || extensionFile ||
228
inputFileRemoved;
229
230
if (reload) {
231
return {
232
config: configFile || configResourceFile || inputFileRemoved,
233
output: false,
234
};
235
} else {
236
return;
237
}
238
} else {
239
return;
240
}
241
} catch (e) {
242
logError(e);
243
return;
244
}
245
};
246
247
// http devserver
248
const devServer = httpDevServer(
249
options.timeout!,
250
() => renderManager.isRendering(),
251
stopServer,
252
);
253
254
// debounced function for notifying all clients of a change
255
// (ensures that we wait for bulk file copying to complete
256
// before triggering the reload)
257
const reloadClients = ld.debounce(async (changes: WatchChanges) => {
258
const services = renderServices(nbContext);
259
try {
260
// fully render project if we aren't already rendering on reload (e.g. for pdf)
261
if (!changes.output && !renderingOnReload) {
262
await refreshProjectConfig();
263
const result = await renderManager.submitRender(() =>
264
renderProject(
265
project,
266
{
267
services,
268
useFreezer: true,
269
devServerReload: true,
270
flags,
271
pandocArgs,
272
previewServer: true,
273
},
274
)
275
);
276
if (result.error) {
277
renderManager.onRenderError(result.error);
278
} else {
279
renderManager.onRenderResult(
280
result,
281
extensionDirs,
282
resourceFiles,
283
project,
284
);
285
}
286
}
287
288
// refresh config if necess
289
if (changes.config) {
290
await refreshProjectConfig();
291
}
292
293
// if this is a reveal presentation running inside rstudio then bail
294
// because rstudio is handling preview separately
295
let reloadTarget = changes.reloadTarget || "";
296
if (reloadTarget && await preventReload(project, reloadTarget, options)) {
297
return;
298
}
299
300
// verify that its okay to reload this file
301
if (reloadTarget && options.navigate) {
302
if (reloadTarget.startsWith(outputDir)) {
303
reloadTarget = relative(outputDir, reloadTarget);
304
} else {
305
reloadTarget = relative(projDir, reloadTarget);
306
}
307
if (existsSync(join(outputDir, reloadTarget))) {
308
reloadTarget = "/" + pathWithForwardSlashes(reloadTarget);
309
} else {
310
reloadTarget = "";
311
}
312
} else {
313
reloadTarget = "";
314
}
315
316
// reload clients
317
devServer.reloadClients(reloadTarget);
318
} catch (e) {
319
logError(e);
320
} finally {
321
services.cleanup();
322
}
323
}, 100);
324
325
// create and run polling fs watcher. we dynamically return the files to watch
326
// based on the current project inputs/config/resources
327
const watcher = watchForFileChanges(() => {
328
return ld.uniq([
329
...project.files.input,
330
...(project.files.resources || []),
331
...(project.files.config || []),
332
...(project.files.configResources || []),
333
...resourceFiles, // statically computed at project startup
334
...extensionFiles, // statically computed at project startup
335
]) as string[];
336
});
337
const watchForChanges = async () => {
338
for await (const event of watcher) {
339
const result = await handleWatchEvent(event);
340
if (result) {
341
await reloadClients(result);
342
}
343
}
344
};
345
watchForChanges();
346
347
// return watcher interface
348
return Promise.resolve({
349
handle: (req: Request) => {
350
return devServer.handle(req);
351
},
352
request: devServer.request,
353
injectClient: (
354
req: Request,
355
file: Uint8Array,
356
inputFile?: string,
357
contentType?: string,
358
) => {
359
return devServer.injectClient(req, file, inputFile, contentType);
360
},
361
clientHtml: (req: Request, inputFile?: string) => {
362
return devServer.clientHtml(req, inputFile);
363
},
364
hasClients: () => devServer.hasClients(),
365
reloadClients: async (output: boolean, reloadTarget?: string) => {
366
await reloadClients({
367
config: false,
368
output,
369
reloadTarget,
370
});
371
},
372
project: () => project,
373
refreshProject: async () => {
374
await refreshProjectConfig();
375
return project;
376
},
377
});
378
}
379
380
interface WatcherOptions {
381
paths: string | string[];
382
options?: { recursive: boolean };
383
}
384
385
async function preventReload(
386
project: ProjectContext,
387
lastHtmlFile: string,
388
options: ServeOptions,
389
) {
390
// if we are in rstudio with watchInputs off then we are using rstudio tooling
391
// for the site preview -- in this case presentations are going to be handled
392
// separately by the presentation pane
393
if (isRStudio() && !options[kProjectWatchInputs]) {
394
const index = await inputTargetIndexForOutputFile(
395
project,
396
relative(projectOutputDir(project), lastHtmlFile),
397
);
398
if (index) {
399
return isRevealjsOutput(Object.keys(index.formats)[0]);
400
}
401
}
402
403
return false;
404
}
405
406