@jsenv/terminal-recorder 1.0.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.
@@ -0,0 +1,149 @@
1
+ import he from "he";
2
+
3
+ export const createXmlGenerator = ({
4
+ rootNodeName,
5
+ canSelfCloseNames = [],
6
+ canReceiveChildNames = [],
7
+ canReceiveContentNames = [],
8
+ }) => {
9
+ const createNode = (name, attributes = {}) => {
10
+ const canSelfClose = canSelfCloseNames.includes(name);
11
+ const canReceiveChild = canReceiveChildNames.includes(name);
12
+ const canReceiveContent = canReceiveContentNames.includes(name);
13
+
14
+ const children = [];
15
+
16
+ const node = {
17
+ name,
18
+ content: "",
19
+ children,
20
+ attributes,
21
+ canSelfClose,
22
+ createNode,
23
+ appendChild: (childNode) => {
24
+ if (!canReceiveChild) {
25
+ throw new Error(`cannot appendChild into ${name}`);
26
+ }
27
+ children.push(childNode);
28
+ return childNode;
29
+ },
30
+ setContent: (value) => {
31
+ if (!canReceiveContent) {
32
+ throw new Error(`cannot setContent on ${name}`);
33
+ }
34
+ node.content = value;
35
+ },
36
+ renderAsString: () => {
37
+ const renderNode = (node, { depth }) => {
38
+ let nodeString = "";
39
+ nodeString += `<${node.name}`;
40
+
41
+ write_attributes: {
42
+ const attributeNames = Object.keys(node.attributes);
43
+ if (attributeNames.length) {
44
+ let attributesSingleLine = "";
45
+ let attributesMultiLine = "";
46
+
47
+ for (const attributeName of attributeNames) {
48
+ let attributeValue = node.attributes[attributeName];
49
+ if (typeof attributeValue === "number") {
50
+ attributeValue = round(attributeValue);
51
+ }
52
+ if (attributeName === "viewBox") {
53
+ attributeValue = attributeValue
54
+ .split(",")
55
+ .map((v) => round(parseFloat(v.trim())))
56
+ .join(", ");
57
+ }
58
+ attributesSingleLine += ` ${attributeName}="${attributeValue}"`;
59
+ attributesMultiLine += `\n `;
60
+ attributesMultiLine += " ".repeat(depth);
61
+ attributesMultiLine += `${attributeName}="${attributeValue}"`;
62
+ }
63
+ attributesMultiLine += "\n";
64
+ attributesMultiLine += " ".repeat(depth);
65
+
66
+ if (attributesSingleLine.length < 100) {
67
+ nodeString += attributesSingleLine;
68
+ } else {
69
+ nodeString += attributesMultiLine;
70
+ }
71
+ }
72
+ }
73
+
74
+ let innerHTML = "";
75
+ if (node.content) {
76
+ if (node.name !== "text") {
77
+ innerHTML += "\n ";
78
+ innerHTML += " ".repeat(depth);
79
+ }
80
+ const contentEncoded = he.encode(node.content, { decimal: false });
81
+ innerHTML += contentEncoded;
82
+ if (node.name !== "text") {
83
+ innerHTML += "\n";
84
+ innerHTML += " ".repeat(depth);
85
+ }
86
+ }
87
+ write_children: {
88
+ if (node.children.length > 0) {
89
+ for (const child of node.children) {
90
+ innerHTML += "\n ";
91
+ innerHTML += " ".repeat(depth);
92
+ innerHTML += renderNode(child, {
93
+ depth: depth + 1,
94
+ });
95
+ }
96
+ innerHTML += "\n";
97
+ innerHTML += " ".repeat(depth);
98
+ }
99
+ }
100
+ if (innerHTML === "") {
101
+ if (node.canSelfClose) {
102
+ nodeString += `/>`;
103
+ } else {
104
+ nodeString += `></${node.name}>`;
105
+ }
106
+ } else {
107
+ nodeString += `>`;
108
+ nodeString += innerHTML;
109
+ nodeString += `</${node.name}>`;
110
+ }
111
+ return nodeString;
112
+ };
113
+
114
+ return renderNode(node, {
115
+ depth: 0,
116
+ });
117
+ },
118
+ };
119
+
120
+ return node;
121
+ };
122
+
123
+ return (rootNodeAttributes) => createNode(rootNodeName, rootNodeAttributes);
124
+ };
125
+
126
+ export const createSvgRootNode = createXmlGenerator({
127
+ rootNodeName: "svg",
128
+ canSelfCloseNames: ["path", "rect", "circle"],
129
+ canReceiveChildNames: ["svg", "foreignObject", "g"],
130
+ canReceiveContentNames: ["text", "style"],
131
+ });
132
+
133
+ // not used for now and needs to configure canReceiveChildNames and so on...
134
+ export const createXmlRootNode = createXmlGenerator({
135
+ rootNodeName: "xml",
136
+ });
137
+
138
+ // not used for now and needs to configure canReceiveChildNames and so on...
139
+ export const createHtmlRootNode = createXmlGenerator({
140
+ rootNodeName: "html",
141
+ });
142
+
143
+ // Round: Make number values smaller in output
144
+ // Eg: 14.23734 becomes 14.24
145
+ // Credit @Chris Martin: https://stackoverflow.com/a/43012696/2816869
146
+ const round = (x) => {
147
+ const rounded = Number(`${Math.round(`${x}e2`)}e-2`);
148
+ return rounded;
149
+ };
@@ -0,0 +1,205 @@
1
+ /*
2
+ - start dev server (must be able to find xterm.html)
3
+ - start chrome
4
+ - visit xterm.html
5
+ - call page.evaluate of initTerminal()
6
+ - call page.evaluate startRecording
7
+ - do some page.evaluate window.writeIntoTerminal()
8
+ - call page.evaluate stopRecording
9
+ - we should have a webm video
10
+ */
11
+
12
+ import { renderTerminalSvg } from "./svg/render_terminal_svg.js";
13
+
14
+ const isDev = process.execArgv.includes("--conditions=development");
15
+
16
+ const startLocalServer = async () => {
17
+ if (isDev) {
18
+ const serverDirectoryUrl = new URL("./client/", import.meta.url);
19
+ const { startDevServer } = await import("@jsenv/core");
20
+ const devServer = await startDevServer({
21
+ logLevel: "warn",
22
+ port: 0,
23
+ sourceDirectoryUrl: serverDirectoryUrl,
24
+ keepProcessAlive: false,
25
+ clientAutoreload: false,
26
+ ribbon: false,
27
+ handleSIGINT: false,
28
+ });
29
+ return devServer;
30
+ }
31
+
32
+ const serverDirectoryUrl = new URL("../../dist/", import.meta.url);
33
+ const { startServer, fetchFileSystem } = await import("@jsenv/server");
34
+ const server = await startServer({
35
+ logLevel: "warn",
36
+ port: 0,
37
+ services: [
38
+ {
39
+ handleRequest: async (request) => {
40
+ const fileUrl = new URL(
41
+ request.resource.slice(1),
42
+ serverDirectoryUrl,
43
+ );
44
+ const response = await fetchFileSystem(fileUrl, request);
45
+ return response;
46
+ },
47
+ },
48
+ ],
49
+ });
50
+ return server;
51
+ };
52
+
53
+ export const startTerminalRecording = async ({
54
+ logs,
55
+ cols,
56
+ rows,
57
+ svg,
58
+ gif,
59
+ video,
60
+ debug,
61
+ } = {}) => {
62
+ const writeCallbackSet = new Set();
63
+ const stopCallbackSet = new Set();
64
+ const terminalRecords = {
65
+ svg: () => {
66
+ throw new Error("svg not recorded");
67
+ },
68
+ gif: () => {
69
+ throw new Error("gif not recorded");
70
+ },
71
+ webm: () => {
72
+ throw new Error("video not recorded");
73
+ },
74
+ mp4: () => {
75
+ throw new Error("video not recorded");
76
+ },
77
+ };
78
+
79
+ if (svg) {
80
+ let ansi = "";
81
+ writeCallbackSet.add((data) => {
82
+ ansi += data;
83
+ });
84
+ stopCallbackSet.add(() => {
85
+ terminalRecords.svg = async () => {
86
+ const terminalSvg = await renderTerminalSvg(ansi, svg);
87
+ ansi = "";
88
+ return terminalSvg;
89
+ };
90
+ });
91
+ }
92
+ if (gif || video) {
93
+ const { chromium } = await import("playwright");
94
+ const server = await startLocalServer();
95
+ const browser = await chromium.launch({
96
+ channel: "chrome", // https://github.com/microsoft/playwright/issues/7716#issuecomment-882634893
97
+ headless: !debug,
98
+ // needed because https-localhost fails to trust cert on chrome + linux (ubuntu 20.04)
99
+ args: ["--ignore-certificate-errors"],
100
+ });
101
+ const page = await browser.newPage({
102
+ ignoreHTTPSErrors: true,
103
+ });
104
+ page.on("pageerror", (error) => {
105
+ throw error;
106
+ });
107
+ page.on("console", (consoleMessage) => {
108
+ console.log(`chrome> ${consoleMessage.text()}`);
109
+ });
110
+ await page.goto(`${server.origin}/xterm.html`);
111
+ await page.evaluate(
112
+ /* eslint-env browser */
113
+ async ({ cols, rows, convertEol, gif, video, logs }) => {
114
+ await window.xtreamReadyPromise;
115
+ const __term__ = await window.initTerminal({
116
+ cols,
117
+ rows,
118
+ convertEol,
119
+ gif,
120
+ video,
121
+ logs,
122
+ });
123
+ window.__term__ = __term__;
124
+ },
125
+ {
126
+ cols,
127
+ rows,
128
+ convertEol: process.platform !== "win32",
129
+ gif,
130
+ video,
131
+ logs,
132
+ },
133
+ /* eslint-env node */
134
+ );
135
+ await page.evaluate(
136
+ /* eslint-env browser */
137
+ async () => {
138
+ const { writeIntoTerminal, stopRecording } =
139
+ await window.__term__.startRecording();
140
+ window.terminalRecording = { writeIntoTerminal, stopRecording };
141
+ },
142
+ /* eslint-env node */
143
+ );
144
+
145
+ writeCallbackSet.add(async (data) => {
146
+ await page.evaluate(
147
+ /* eslint-env browser */
148
+ async (data) => {
149
+ await window.terminalRecording.writeIntoTerminal(data);
150
+ },
151
+ data,
152
+ /* eslint-env node */
153
+ );
154
+ });
155
+
156
+ stopCallbackSet.add(async () => {
157
+ const recordedFormats = await page.evaluate(
158
+ /* eslint-env browser */
159
+ () => {
160
+ return window.terminalRecording.stopRecording();
161
+ },
162
+ /* eslint-env node */
163
+ );
164
+ if (!debug) {
165
+ server.stop();
166
+ browser.close();
167
+ }
168
+
169
+ terminalRecords.gif = () => {
170
+ const terminalGifBuffer = Buffer.from(recordedFormats.gif, "binary");
171
+ return terminalGifBuffer;
172
+ };
173
+ terminalRecords.webm = () => {
174
+ const terminalWebmBuffer = Buffer.from(recordedFormats.video, "binary");
175
+ return terminalWebmBuffer;
176
+ };
177
+ terminalRecords.mp4 = async () => {
178
+ const terminalWebmBuffer = Buffer.from(terminalRecords.video, "binary");
179
+ const webmToMp4Namespace = await import("webm-to-mp4");
180
+ const webmToMp4 = webmToMp4Namespace.default;
181
+ const terminalMp4Buffer = Buffer.from(webmToMp4(terminalWebmBuffer));
182
+ return terminalMp4Buffer;
183
+ };
184
+ });
185
+ }
186
+ return {
187
+ write: async (data) => {
188
+ const promises = [];
189
+ for (const writeCallback of writeCallbackSet) {
190
+ promises.push(writeCallback(data));
191
+ }
192
+ await Promise.all(promises);
193
+ },
194
+ stop: async () => {
195
+ const promises = [];
196
+ for (const stopCallback of stopCallbackSet) {
197
+ promises.push(stopCallback());
198
+ }
199
+ writeCallbackSet.clear();
200
+ stopCallbackSet.clear();
201
+ await Promise.all(promises);
202
+ return terminalRecords;
203
+ },
204
+ };
205
+ };