@tutorialkit-rb/runtime 1.5.2-rb.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/README.md +18 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/lesson-files.d.ts +22 -0
- package/dist/lesson-files.js +126 -0
- package/dist/store/editor.d.ts +29 -0
- package/dist/store/editor.js +127 -0
- package/dist/store/index.d.ts +113 -0
- package/dist/store/index.js +316 -0
- package/dist/store/previews.d.ts +19 -0
- package/dist/store/previews.js +80 -0
- package/dist/store/terminal.d.ts +24 -0
- package/dist/store/terminal.js +128 -0
- package/dist/store/tutorial-runner.d.ts +147 -0
- package/dist/store/tutorial-runner.js +564 -0
- package/dist/tasks.d.ts +22 -0
- package/dist/tasks.js +26 -0
- package/dist/utils/multi-counter.d.ts +5 -0
- package/dist/utils/multi-counter.js +19 -0
- package/dist/utils/promises.d.ts +8 -0
- package/dist/utils/promises.js +29 -0
- package/dist/utils/support.d.ts +1 -0
- package/dist/utils/support.js +23 -0
- package/dist/utils/terminal.d.ts +17 -0
- package/dist/utils/terminal.js +13 -0
- package/dist/webcontainer/command.d.ts +28 -0
- package/dist/webcontainer/command.js +67 -0
- package/dist/webcontainer/editor-config.d.ts +12 -0
- package/dist/webcontainer/editor-config.js +60 -0
- package/dist/webcontainer/index.d.ts +4 -0
- package/dist/webcontainer/index.js +4 -0
- package/dist/webcontainer/on-demand-boot.d.ts +15 -0
- package/dist/webcontainer/on-demand-boot.js +39 -0
- package/dist/webcontainer/port-info.d.ts +6 -0
- package/dist/webcontainer/port-info.js +10 -0
- package/dist/webcontainer/preview-info.d.ts +21 -0
- package/dist/webcontainer/preview-info.js +56 -0
- package/dist/webcontainer/shell.d.ts +14 -0
- package/dist/webcontainer/shell.js +46 -0
- package/dist/webcontainer/steps.d.ts +15 -0
- package/dist/webcontainer/steps.js +38 -0
- package/dist/webcontainer/terminal-config.d.ts +59 -0
- package/dist/webcontainer/terminal-config.js +230 -0
- package/dist/webcontainer/utils/files.d.ts +10 -0
- package/dist/webcontainer/utils/files.js +76 -0
- package/package.json +53 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import picomatch from 'picomatch/posix.js';
|
|
2
|
+
import { newTask } from '../tasks.js';
|
|
3
|
+
import { MultiCounter } from '../utils/multi-counter.js';
|
|
4
|
+
import { clearTerminal, escapeCodes } from '../utils/terminal.js';
|
|
5
|
+
import { Command, Commands } from '../webcontainer/command.js';
|
|
6
|
+
import { StepsController } from '../webcontainer/steps.js';
|
|
7
|
+
import { diffFiles, toFileTree } from '../webcontainer/utils/files.js';
|
|
8
|
+
/**
|
|
9
|
+
* The idea behind this class is that it manages the state of WebContainer and exposes
|
|
10
|
+
* an interface that makes sense to every component of TutorialKit.
|
|
11
|
+
*
|
|
12
|
+
* There should be only a single instance of this class.
|
|
13
|
+
*/
|
|
14
|
+
export class TutorialRunner {
|
|
15
|
+
_webcontainer;
|
|
16
|
+
_terminalStore;
|
|
17
|
+
_editorStore;
|
|
18
|
+
_stepController;
|
|
19
|
+
_currentLoadTask = undefined;
|
|
20
|
+
_currentProcessTask = undefined;
|
|
21
|
+
_currentCommandProcess = undefined;
|
|
22
|
+
_currentTemplate = undefined;
|
|
23
|
+
_currentFiles = undefined;
|
|
24
|
+
_currentRunCommands = undefined;
|
|
25
|
+
_ignoreFileEvents = new MultiCounter();
|
|
26
|
+
_watcher;
|
|
27
|
+
_watchContentFromWebContainer = false;
|
|
28
|
+
_readyToWatch = false;
|
|
29
|
+
_packageJsonDirty = false;
|
|
30
|
+
_commandsChanged = false;
|
|
31
|
+
// this strongly assumes that there's a single package json which might not be true
|
|
32
|
+
_packageJsonContent = '';
|
|
33
|
+
_packageJsonPath = '';
|
|
34
|
+
constructor(_webcontainer, _terminalStore, _editorStore, _stepController) {
|
|
35
|
+
this._webcontainer = _webcontainer;
|
|
36
|
+
this._terminalStore = _terminalStore;
|
|
37
|
+
this._editorStore = _editorStore;
|
|
38
|
+
this._stepController = _stepController;
|
|
39
|
+
}
|
|
40
|
+
setWatchFromWebContainer(value) {
|
|
41
|
+
this._watchContentFromWebContainer = value;
|
|
42
|
+
if (this._readyToWatch && this._watchContentFromWebContainer) {
|
|
43
|
+
this._webcontainer.then((webcontainer) => this._setupWatcher(webcontainer));
|
|
44
|
+
}
|
|
45
|
+
else if (!this._watchContentFromWebContainer) {
|
|
46
|
+
this._stopWatcher();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Set the commands to run. This updates the reported `steps` if any have changed.
|
|
51
|
+
*
|
|
52
|
+
* This function is safe to call server side.
|
|
53
|
+
*
|
|
54
|
+
* To actually run them in WebContainer see `runCommands`.
|
|
55
|
+
*
|
|
56
|
+
* @param commands The commands schema.
|
|
57
|
+
*/
|
|
58
|
+
setCommands(commands) {
|
|
59
|
+
const newCommands = new Commands(commands);
|
|
60
|
+
const anyChange = this._changeDetection(commands);
|
|
61
|
+
// if we already know that there's a change we can update the steps now
|
|
62
|
+
if (anyChange) {
|
|
63
|
+
this._stepController.setFromCommands(Array.from(newCommands));
|
|
64
|
+
this._currentRunCommands = newCommands;
|
|
65
|
+
this._commandsChanged = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
onTerminalResize(cols, rows) {
|
|
69
|
+
this._currentCommandProcess?.resize({ cols, rows });
|
|
70
|
+
}
|
|
71
|
+
createFolder(folderPath) {
|
|
72
|
+
const previousLoadPromise = this._currentLoadTask?.promise;
|
|
73
|
+
this._currentLoadTask = newTask(async (signal) => {
|
|
74
|
+
await previousLoadPromise;
|
|
75
|
+
const webcontainer = await this._webcontainer;
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
this._ignoreFileEvents.increment(folderPath);
|
|
78
|
+
await webcontainer.fs.mkdir(folderPath);
|
|
79
|
+
}, { ignoreCancel: true });
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Update the content of a single file in WebContainer.
|
|
83
|
+
*
|
|
84
|
+
* @param filePath path of the file
|
|
85
|
+
* @param content new content of the file
|
|
86
|
+
*/
|
|
87
|
+
updateFile(filePath, content) {
|
|
88
|
+
const previousLoadPromise = this._currentLoadTask?.promise;
|
|
89
|
+
this._currentLoadTask = newTask(async (signal) => {
|
|
90
|
+
await previousLoadPromise;
|
|
91
|
+
const webcontainer = await this._webcontainer;
|
|
92
|
+
signal.throwIfAborted();
|
|
93
|
+
this._ignoreFileEvents.increment(filePath);
|
|
94
|
+
await webcontainer.fs.writeFile(filePath, content);
|
|
95
|
+
this._updateCurrentFiles({ [filePath]: content });
|
|
96
|
+
}, { ignoreCancel: true });
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Update the provided files in WebContainer.
|
|
100
|
+
*
|
|
101
|
+
* @param files Files to update.
|
|
102
|
+
*/
|
|
103
|
+
updateFiles(files) {
|
|
104
|
+
const previousLoadPromise = this._currentLoadTask?.promise;
|
|
105
|
+
this._currentLoadTask = newTask(async (signal) => {
|
|
106
|
+
await previousLoadPromise;
|
|
107
|
+
const webcontainer = await this._webcontainer;
|
|
108
|
+
signal.throwIfAborted();
|
|
109
|
+
this._ignoreFileEvents.increment(Object.keys(files));
|
|
110
|
+
await webcontainer.mount(toFileTree(files));
|
|
111
|
+
this._updateCurrentFiles(files);
|
|
112
|
+
}, { ignoreCancel: true });
|
|
113
|
+
}
|
|
114
|
+
async fileExists(filepath) {
|
|
115
|
+
return this._fsExists(filepath, 'file');
|
|
116
|
+
}
|
|
117
|
+
async folderExists(folderPath) {
|
|
118
|
+
return this._fsExists(folderPath, 'folder');
|
|
119
|
+
}
|
|
120
|
+
async _fsExists(filepath, type) {
|
|
121
|
+
if (this._currentFiles?.[filepath] || this._currentTemplate?.[filepath]) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const previousLoadPromise = this._currentLoadTask?.promise;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
this._currentLoadTask = newTask(async () => {
|
|
127
|
+
await previousLoadPromise;
|
|
128
|
+
const webcontainer = await this._webcontainer;
|
|
129
|
+
try {
|
|
130
|
+
if (type === 'file') {
|
|
131
|
+
await webcontainer.fs.readFile(filepath);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
await webcontainer.fs.readdir(filepath);
|
|
135
|
+
}
|
|
136
|
+
resolve(true);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
resolve(false);
|
|
140
|
+
}
|
|
141
|
+
}, { ignoreCancel: true });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Load the provided files into WebContainer and remove any other files that had been loaded previously.
|
|
146
|
+
*
|
|
147
|
+
* This function always waits for any previous `prepareFiles` or `updateFile(s)` call to have completed
|
|
148
|
+
* before sending the next one.
|
|
149
|
+
*
|
|
150
|
+
* Previous load operations will be cancelled if `options.abortPreviousLoad` was set to true (which is the default).
|
|
151
|
+
*
|
|
152
|
+
* @see {LoadFilesOptions}
|
|
153
|
+
*/
|
|
154
|
+
prepareFiles({ files, template, signal, abortPreviousLoad = true, removePaths, }) {
|
|
155
|
+
const previousLoadPromise = this._currentLoadTask?.promise;
|
|
156
|
+
if (abortPreviousLoad) {
|
|
157
|
+
this._currentLoadTask?.cancel();
|
|
158
|
+
}
|
|
159
|
+
this._currentLoadTask = newTask(async (signal) => {
|
|
160
|
+
await previousLoadPromise;
|
|
161
|
+
// no watcher should be installed
|
|
162
|
+
this._readyToWatch = false;
|
|
163
|
+
// stop current watcher if they are any
|
|
164
|
+
this._stopWatcher();
|
|
165
|
+
const webcontainer = await this._webcontainer;
|
|
166
|
+
signal.throwIfAborted();
|
|
167
|
+
[template, files] = await Promise.all([template, files]);
|
|
168
|
+
signal.throwIfAborted();
|
|
169
|
+
if (this._currentFiles || this._currentTemplate) {
|
|
170
|
+
if (removePaths) {
|
|
171
|
+
for (const path of removePaths) {
|
|
172
|
+
if (this._currentFiles) {
|
|
173
|
+
for (const filePath of Object.keys(this._currentFiles)) {
|
|
174
|
+
if (filePath.startsWith(path)) {
|
|
175
|
+
delete this._currentFiles[filePath];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await webcontainer.fs.rm(path, { recursive: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await updateFiles(webcontainer, { ...this._currentTemplate, ...this._currentFiles }, { ...template, ...files });
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
await webcontainer.mount(toFileTree({ ...template, ...files }));
|
|
186
|
+
}
|
|
187
|
+
this._currentTemplate = { ...template };
|
|
188
|
+
this._currentFiles = { ...files };
|
|
189
|
+
this._updateDirtyState({ ...template, ...files });
|
|
190
|
+
}, { ignoreCancel: true, signal });
|
|
191
|
+
return this._currentLoadTask.promise;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Runs the list of commands set with `setCommands`.
|
|
195
|
+
*
|
|
196
|
+
* This function always wait for any previous `runCommands` call to have completed before sending the next one.
|
|
197
|
+
* It will cancel the previous operation if `options.abortPreviousRun` was set to true.
|
|
198
|
+
*
|
|
199
|
+
* Commands are split into two:
|
|
200
|
+
*
|
|
201
|
+
* - `prepareCommands`: For example commands like `npm install`, `mkdir -p src/foobar`, etc.
|
|
202
|
+
* - `mainCommand`: Used to for example run a dev server or equivalent.
|
|
203
|
+
*
|
|
204
|
+
* @see {LoadFilesOptions}
|
|
205
|
+
*/
|
|
206
|
+
runCommands({ abortPreviousRun = true } = {}) {
|
|
207
|
+
const previousTask = this._currentProcessTask;
|
|
208
|
+
const loadPromise = this._currentLoadTask?.promise;
|
|
209
|
+
const newCommands = this._currentRunCommands;
|
|
210
|
+
const commandsChanged = this._commandsChanged;
|
|
211
|
+
if (!newCommands) {
|
|
212
|
+
throw new Error('setCommands should be called before runCommands');
|
|
213
|
+
}
|
|
214
|
+
this._currentProcessTask = newTask(async (signal) => {
|
|
215
|
+
/**
|
|
216
|
+
* Make sure we wait for everything to be loaded on the fs before
|
|
217
|
+
* checking for changes. We do this because we want to know if the
|
|
218
|
+
* `package.json` changed.
|
|
219
|
+
*/
|
|
220
|
+
await loadPromise;
|
|
221
|
+
if (signal.aborted && abortPreviousRun) {
|
|
222
|
+
previousTask?.cancel();
|
|
223
|
+
}
|
|
224
|
+
signal.throwIfAborted();
|
|
225
|
+
const anyChange = this._packageJsonDirty || commandsChanged;
|
|
226
|
+
if (!anyChange) {
|
|
227
|
+
/**
|
|
228
|
+
* If there are no changes and we have a previous task, then
|
|
229
|
+
* we must link this new task to that previous one, otherwise
|
|
230
|
+
* the link is broken and that task will never ends.
|
|
231
|
+
*
|
|
232
|
+
* We create that link here by awaiting it. Note that this `if`
|
|
233
|
+
* here should always evaluate to true.
|
|
234
|
+
*/
|
|
235
|
+
if (previousTask) {
|
|
236
|
+
const abortListener = () => previousTask.cancel();
|
|
237
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
238
|
+
return previousTask.promise;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
// there were changes so we reset the "commands changed"
|
|
243
|
+
this._commandsChanged = false;
|
|
244
|
+
if (abortPreviousRun) {
|
|
245
|
+
previousTask?.cancel();
|
|
246
|
+
}
|
|
247
|
+
await previousTask?.promise;
|
|
248
|
+
const webcontainer = await this._webcontainer;
|
|
249
|
+
signal.throwIfAborted();
|
|
250
|
+
return this._runCommands(webcontainer, newCommands, signal);
|
|
251
|
+
}, { ignoreCancel: true });
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Restart the last run commands that were submitted.
|
|
255
|
+
*/
|
|
256
|
+
restartLastRunCommands() {
|
|
257
|
+
if (!this._currentRunCommands || !this._currentProcessTask) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const previousRunCommands = this._currentRunCommands;
|
|
261
|
+
const previousProcessPromise = this._currentProcessTask.promise;
|
|
262
|
+
const loadPromise = this._currentLoadTask?.promise;
|
|
263
|
+
this._currentProcessTask.cancel();
|
|
264
|
+
this._currentProcessTask = newTask(async (signal) => {
|
|
265
|
+
await Promise.all([previousProcessPromise, loadPromise]);
|
|
266
|
+
const webcontainer = await this._webcontainer;
|
|
267
|
+
signal.throwIfAborted();
|
|
268
|
+
return this._runCommands(webcontainer, previousRunCommands, signal);
|
|
269
|
+
}, { ignoreCancel: true });
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Get snapshot of runner's current files.
|
|
273
|
+
* Also prepares `package.json`'s `stackblitz.startCommand` with runner's commands.
|
|
274
|
+
*
|
|
275
|
+
* Note that file paths do not contain the leading `/`.
|
|
276
|
+
*/
|
|
277
|
+
takeSnapshot() {
|
|
278
|
+
const files = {};
|
|
279
|
+
// first add template files
|
|
280
|
+
for (const [filePath, value] of Object.entries(this._currentTemplate || {})) {
|
|
281
|
+
if (typeof value === 'string') {
|
|
282
|
+
files[filePath.slice(1)] = value;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// next overwrite with files from editor
|
|
286
|
+
for (const [filePath, value] of Object.entries(this._currentFiles || {})) {
|
|
287
|
+
if (typeof value === 'string') {
|
|
288
|
+
files[filePath.slice(1)] = value;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (this._packageJsonContent) {
|
|
292
|
+
let packageJson;
|
|
293
|
+
try {
|
|
294
|
+
packageJson = JSON.parse(this._packageJsonContent);
|
|
295
|
+
}
|
|
296
|
+
catch { }
|
|
297
|
+
// add start commands when missing
|
|
298
|
+
if (packageJson && !packageJson.stackblitz?.startCommand) {
|
|
299
|
+
const mainCommand = this._currentRunCommands?.mainCommand?.shellCommand;
|
|
300
|
+
const prepareCommands = (this._currentRunCommands?.prepareCommands || []).map((c) => c.shellCommand);
|
|
301
|
+
const startCommand = [...prepareCommands, mainCommand].filter(Boolean).join(' && ');
|
|
302
|
+
files[this._packageJsonPath.slice(1)] = JSON.stringify({ ...packageJson, stackblitz: { startCommand } }, null, 2);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { files };
|
|
306
|
+
}
|
|
307
|
+
async _runCommands(webcontainer, commands, signal) {
|
|
308
|
+
const output = this._terminalStore.getOutputPanel();
|
|
309
|
+
clearTerminal(output);
|
|
310
|
+
const abortListener = () => this._currentCommandProcess?.kill();
|
|
311
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
312
|
+
let shouldClearDirtyFlag = true;
|
|
313
|
+
try {
|
|
314
|
+
const commandList = Array.from(commands);
|
|
315
|
+
this._stepController.setFromCommands(commandList);
|
|
316
|
+
// keep track of the current runnable command we are on
|
|
317
|
+
let runnableCommands = 0;
|
|
318
|
+
for (const [index, command] of commandList.entries()) {
|
|
319
|
+
const isMainCommand = index === commandList.length - 1 && !!commands.mainCommand;
|
|
320
|
+
if (!command.isRunnable()) {
|
|
321
|
+
this._stepController.updateStep(index, {
|
|
322
|
+
title: command.title,
|
|
323
|
+
status: 'skipped',
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
this._stepController.updateStep(index, {
|
|
328
|
+
title: command.title,
|
|
329
|
+
status: 'running',
|
|
330
|
+
});
|
|
331
|
+
// print newlines between commands to visually separate them from one another
|
|
332
|
+
if (runnableCommands > 0) {
|
|
333
|
+
output?.write('\n');
|
|
334
|
+
}
|
|
335
|
+
runnableCommands++;
|
|
336
|
+
this._currentCommandProcess = await this._newProcess(webcontainer, output, command.shellCommand);
|
|
337
|
+
try {
|
|
338
|
+
signal.throwIfAborted();
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
this._stepController.skipRemaining(index);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
if (isMainCommand) {
|
|
345
|
+
shouldClearDirtyFlag = false;
|
|
346
|
+
this._setupWatcher(webcontainer);
|
|
347
|
+
this._clearDirtyState();
|
|
348
|
+
}
|
|
349
|
+
const exitCode = await this._currentCommandProcess.exit;
|
|
350
|
+
if (exitCode !== 0) {
|
|
351
|
+
this._stepController.updateStep(index, {
|
|
352
|
+
title: command.title,
|
|
353
|
+
status: 'failed',
|
|
354
|
+
});
|
|
355
|
+
this._stepController.skipRemaining(index + 1);
|
|
356
|
+
/**
|
|
357
|
+
* We don't clear the dirty flag in that case as there was an error and re-running all commands
|
|
358
|
+
* is the probably better than not running anything.
|
|
359
|
+
*/
|
|
360
|
+
shouldClearDirtyFlag = false;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
this._stepController.updateStep(index, {
|
|
365
|
+
title: command.title,
|
|
366
|
+
status: 'completed',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
signal.throwIfAborted();
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
this._stepController.skipRemaining(index + 1);
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* All commands were run but we didn't clear the dirty state.
|
|
379
|
+
* We have to, otherwise we would re-run those commands when moving
|
|
380
|
+
* to a lesson that has the exact same set of commands.
|
|
381
|
+
*/
|
|
382
|
+
if (shouldClearDirtyFlag) {
|
|
383
|
+
this._clearDirtyState();
|
|
384
|
+
}
|
|
385
|
+
// make sure the watcher is configured
|
|
386
|
+
this._setupWatcher(webcontainer);
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
signal.removeEventListener('abort', abortListener);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async _newProcess(webcontainer, output, shellCommand) {
|
|
393
|
+
const [command, ...args] = shellCommand.split(' ');
|
|
394
|
+
output?.write(`${escapeCodes.magenta('❯')} ${escapeCodes.green(command)} ${args.join(' ')}\n`);
|
|
395
|
+
/**
|
|
396
|
+
* We spawn the process and use a fallback for cols and rows in case the output is not connected to a visible
|
|
397
|
+
* terminal yet.
|
|
398
|
+
*/
|
|
399
|
+
const process = await webcontainer.spawn(command, args, {
|
|
400
|
+
terminal: output
|
|
401
|
+
? {
|
|
402
|
+
cols: output.cols ?? 80,
|
|
403
|
+
rows: output.rows ?? 15,
|
|
404
|
+
}
|
|
405
|
+
: undefined,
|
|
406
|
+
});
|
|
407
|
+
process.output.pipeTo(new WritableStream({ write: (data) => output?.write(data) }));
|
|
408
|
+
return process;
|
|
409
|
+
}
|
|
410
|
+
_updateDirtyState(files) {
|
|
411
|
+
for (const filePath in files) {
|
|
412
|
+
if (filePath.endsWith('/package.json') && files[filePath] != this._packageJsonContent) {
|
|
413
|
+
this._packageJsonContent = files[filePath];
|
|
414
|
+
this._packageJsonPath = filePath;
|
|
415
|
+
this._packageJsonDirty = true;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
_updateCurrentFiles(files) {
|
|
421
|
+
// if the file was not tracked by the existing list of files, add it
|
|
422
|
+
if (this._currentFiles) {
|
|
423
|
+
for (const filePath in files) {
|
|
424
|
+
this._currentFiles[filePath] = files[filePath];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
this._currentFiles = { ...files };
|
|
429
|
+
}
|
|
430
|
+
this._updateDirtyState(files);
|
|
431
|
+
}
|
|
432
|
+
_stopWatcher() {
|
|
433
|
+
// if there was a watcher terminate it
|
|
434
|
+
if (this._watcher) {
|
|
435
|
+
this._watcher.close();
|
|
436
|
+
this._watcher = undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
_setupWatcher(webcontainer) {
|
|
440
|
+
// inform that the watcher could be installed if we wanted to
|
|
441
|
+
this._readyToWatch = true;
|
|
442
|
+
// if the watcher is alreay setup or we don't sync content we exit
|
|
443
|
+
if (this._watcher || !this._watchContentFromWebContainer) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const filesToRead = new Map();
|
|
447
|
+
let timeoutId;
|
|
448
|
+
const readFiles = () => {
|
|
449
|
+
const files = [...filesToRead.entries()];
|
|
450
|
+
filesToRead.clear();
|
|
451
|
+
Promise.all(files.map(async ([filePath, encoding]) => {
|
|
452
|
+
// casts could be removed with an `if` but it feels weird
|
|
453
|
+
const content = (await webcontainer.fs.readFile(filePath, encoding));
|
|
454
|
+
return [filePath, content];
|
|
455
|
+
})).then((fileContents) => {
|
|
456
|
+
for (const [filePath, content] of fileContents) {
|
|
457
|
+
this._editorStore.updateFile(filePath, content);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
/**
|
|
462
|
+
* Add a file to the list of files to read and schedule a read for later, effectively debouncing the reads.
|
|
463
|
+
*
|
|
464
|
+
* This does not cancel any existing requests because those are expected to be completed really
|
|
465
|
+
* fast. However every read request allocate memory that needs to be freed. The reason we debounce
|
|
466
|
+
* is to avoid running into OOM issues (which has happened in the past) and give time to the GC to
|
|
467
|
+
* cleanup the allocated buffers.
|
|
468
|
+
*/
|
|
469
|
+
const scheduleReadFor = (filePath, encoding) => {
|
|
470
|
+
filesToRead.set(filePath, encoding);
|
|
471
|
+
clearTimeout(timeoutId);
|
|
472
|
+
timeoutId = setTimeout(readFiles, 100);
|
|
473
|
+
};
|
|
474
|
+
this._watcher = webcontainer.fs.watch('.', { recursive: true }, async (eventType, filename) => {
|
|
475
|
+
const filePath = `/${filename}`;
|
|
476
|
+
// events we should ignore because we caused them in the TutorialRunner
|
|
477
|
+
if (!this._ignoreFileEvents.decrement(filePath)) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (Array.isArray(this._watchContentFromWebContainer) &&
|
|
481
|
+
!this._watchContentFromWebContainer.some((pattern) => picomatch.isMatch(filePath, pattern))) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (eventType === 'change') {
|
|
485
|
+
/**
|
|
486
|
+
* Update file
|
|
487
|
+
* we ignore all paths that aren't exposed in the `_editorStore`
|
|
488
|
+
*/
|
|
489
|
+
const file = this._editorStore.documents.get()[filePath];
|
|
490
|
+
if (!file) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null);
|
|
494
|
+
}
|
|
495
|
+
else if (eventType === 'rename' && Array.isArray(this._watchContentFromWebContainer)) {
|
|
496
|
+
const file = this._editorStore.documents.get()[filePath];
|
|
497
|
+
if (file) {
|
|
498
|
+
// remove file
|
|
499
|
+
this._editorStore.deleteFile(filePath);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
// add file
|
|
503
|
+
const segments = filePath.split('/');
|
|
504
|
+
for (let index = 0; index < segments.length - 1; index++) {
|
|
505
|
+
const folderPath = segments.slice(0, index + 1).join('/');
|
|
506
|
+
if (!this._editorStore.documents.get()[folderPath]) {
|
|
507
|
+
const isDirectory = await _isDirectory(webcontainer, folderPath);
|
|
508
|
+
this._editorStore.addFileOrFolder({ path: folderPath, type: isDirectory ? 'folder' : 'file' });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!this._editorStore.documents.get()[filePath]) {
|
|
512
|
+
this._editorStore.addFileOrFolder({ path: filePath, type: 'file' });
|
|
513
|
+
}
|
|
514
|
+
this._updateCurrentFiles({ [filePath]: '' });
|
|
515
|
+
scheduleReadFor(filePath, 'utf-8');
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
_clearDirtyState() {
|
|
521
|
+
this._packageJsonDirty = false;
|
|
522
|
+
}
|
|
523
|
+
_changeDetection(newCommands) {
|
|
524
|
+
if (this._packageJsonDirty) {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
if (!this._currentRunCommands) {
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
const prevCommandList = commandsToList(this._currentRunCommands);
|
|
531
|
+
const newCommandList = commandsToList(newCommands);
|
|
532
|
+
if (prevCommandList.length !== newCommandList.length) {
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
for (let i = 0; i < prevCommandList.length; ++i) {
|
|
536
|
+
if (!Command.equals(prevCommandList[i], newCommandList[i])) {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function commandsToList(commands) {
|
|
544
|
+
if (commands instanceof Commands) {
|
|
545
|
+
return Array.from(commands).filter((command) => command.isRunnable());
|
|
546
|
+
}
|
|
547
|
+
return commandsToList(new Commands(commands));
|
|
548
|
+
}
|
|
549
|
+
async function updateFiles(webcontainer, previousFiles, newFiles) {
|
|
550
|
+
const { removed, addedOrModified } = diffFiles(previousFiles, newFiles);
|
|
551
|
+
for (const filePath of removed) {
|
|
552
|
+
await webcontainer.fs.rm(filePath, { force: true });
|
|
553
|
+
}
|
|
554
|
+
await webcontainer.mount(toFileTree(addedOrModified));
|
|
555
|
+
}
|
|
556
|
+
async function _isDirectory(webcontainer, filePath) {
|
|
557
|
+
try {
|
|
558
|
+
await webcontainer.fs.readdir(filePath);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const kTaskCancelled: unique symbol;
|
|
2
|
+
export type TaskCancelled = typeof kTaskCancelled;
|
|
3
|
+
export interface Task<T> {
|
|
4
|
+
promise: Promise<T>;
|
|
5
|
+
cancel(): void;
|
|
6
|
+
}
|
|
7
|
+
export declare class AbortError extends Error {
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A helper function to easily create "cancellable" promises where
|
|
11
|
+
* once a promise is cancelled it resolves to the "cancel" symbol.
|
|
12
|
+
*
|
|
13
|
+
* @param task - A function that return a promise.
|
|
14
|
+
* @returns The newly created task.
|
|
15
|
+
*/
|
|
16
|
+
export declare function newTask<T>(task: (abortSignal: AbortSignal) => Promise<T>, opts: {
|
|
17
|
+
ignoreCancel: true;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}): Task<T | TaskCancelled>;
|
|
20
|
+
export declare function newTask<T>(task: (abortSignal: AbortSignal) => Promise<T>, opts?: {
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}): Task<T>;
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const kTaskCancelled = Symbol('kTaskCancelled');
|
|
2
|
+
export class AbortError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export function newTask(task, opts = {}) {
|
|
5
|
+
const abortController = new AbortController();
|
|
6
|
+
const abortListener = () => abortController.abort(new AbortError());
|
|
7
|
+
const signal = opts.signal;
|
|
8
|
+
let runningTask = task(abortController.signal);
|
|
9
|
+
if (signal) {
|
|
10
|
+
runningTask = runningTask.finally(() => signal.removeEventListener('abort', abortListener));
|
|
11
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
promise: !opts.ignoreCancel
|
|
15
|
+
? runningTask
|
|
16
|
+
: runningTask.catch((reason) => {
|
|
17
|
+
if (!(reason instanceof AbortError)) {
|
|
18
|
+
throw reason;
|
|
19
|
+
}
|
|
20
|
+
return kTaskCancelled;
|
|
21
|
+
}),
|
|
22
|
+
cancel() {
|
|
23
|
+
abortController.abort(new AbortError());
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class MultiCounter {
|
|
2
|
+
_counts = new Map();
|
|
3
|
+
increment(name) {
|
|
4
|
+
if (typeof name === 'string') {
|
|
5
|
+
const currentValue = this._counts.get(name) ?? 0;
|
|
6
|
+
this._counts.set(name, currentValue + 1);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
name.forEach((value) => this.increment(value));
|
|
10
|
+
}
|
|
11
|
+
decrement(name) {
|
|
12
|
+
const currentValue = this._counts.get(name) ?? 0;
|
|
13
|
+
if (currentValue === 0) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
this._counts.set(name, currentValue - 1);
|
|
17
|
+
return currentValue - 1 === 0;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function withResolvers<T>(): PromiseWithResolvers<T>;
|
|
2
|
+
export declare function wait(ms: number): Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* Simulates a single tick of the event loop.
|
|
5
|
+
*
|
|
6
|
+
* @returns A promise that resolves after the tick.
|
|
7
|
+
*/
|
|
8
|
+
export declare function tick(): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function withResolvers() {
|
|
2
|
+
if (typeof Promise.withResolvers === 'function') {
|
|
3
|
+
return Promise.withResolvers();
|
|
4
|
+
}
|
|
5
|
+
let resolve;
|
|
6
|
+
let reject;
|
|
7
|
+
const promise = new Promise((_resolve, _reject) => {
|
|
8
|
+
resolve = _resolve;
|
|
9
|
+
reject = _reject;
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
resolve,
|
|
13
|
+
reject,
|
|
14
|
+
promise,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function wait(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Simulates a single tick of the event loop.
|
|
22
|
+
*
|
|
23
|
+
* @returns A promise that resolves after the tick.
|
|
24
|
+
*/
|
|
25
|
+
export function tick() {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
setTimeout(resolve);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isWebContainerSupported(): boolean;
|