miro-export 1.1.1 → 1.3.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.
@@ -8,11 +8,11 @@ jobs:
8
8
  build:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
- - uses: actions/checkout@v4
12
- - uses: pnpm/action-setup@v3
11
+ - uses: actions/checkout@v6
12
+ - uses: pnpm/action-setup@v4
13
13
  with:
14
14
  version: 8
15
- - uses: actions/setup-node@v4
15
+ - uses: actions/setup-node@v6
16
16
  with:
17
17
  node-version: "22.x"
18
18
  - name: Install dependencies
@@ -22,7 +22,7 @@ jobs:
22
22
  - name: Build
23
23
  run: pnpm run build
24
24
  - name: Upload Artifact
25
- uses: actions/upload-artifact@v4
25
+ uses: actions/upload-artifact@v6
26
26
  with:
27
27
  name: build
28
28
  path: build/
@@ -8,11 +8,11 @@ jobs:
8
8
  publish:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
- - uses: actions/checkout@v4
12
- - uses: pnpm/action-setup@v3
11
+ - uses: actions/checkout@v6
12
+ - uses: pnpm/action-setup@v4
13
13
  with:
14
14
  version: 8
15
- - uses: actions/setup-node@v4
15
+ - uses: actions/setup-node@v6
16
16
  with:
17
17
  node-version: "22.x"
18
18
  registry-url: "https://registry.npmjs.org"
@@ -6,13 +6,13 @@ on:
6
6
 
7
7
  jobs:
8
8
  test:
9
- runs-on: ubuntu-latest
9
+ runs-on: ubuntu-22.04
10
10
  steps:
11
- - uses: actions/checkout@v4
12
- - uses: pnpm/action-setup@v3
11
+ - uses: actions/checkout@v6
12
+ - uses: pnpm/action-setup@v4
13
13
  with:
14
14
  version: 8
15
- - uses: actions/setup-node@v4
15
+ - uses: actions/setup-node@v6
16
16
  with:
17
17
  node-version: "22.x"
18
18
  - name: Install dependencies
@@ -21,3 +21,5 @@ jobs:
21
21
  run: pnpm run test
22
22
  env:
23
23
  TEST_BOARD_ID: ${{secrets.TEST_BOARD_ID}}
24
+ PRIVATE_TEST_BOARD_ID: ${{secrets.PRIVATE_TEST_BOARD_ID}}
25
+ BUGGY_TEST_BOARD_ID: ${{secrets.BUGGY_TEST_BOARD_ID}}
package/README.md CHANGED
@@ -30,8 +30,9 @@ Options:
30
30
  -t, --token <token> Miro token
31
31
  -b, --board-id <boardId> The board ID
32
32
  -f, --frame-names <frameNames...> The frame name(s), leave empty to export entire board
33
- -o, --output-file <filename> A file to output the SVG to (stdout if not supplied)
34
- -e, --export-format <format> 'svg' or 'json' (default: 'svg')
33
+ -o, --output-file <filename> A file to output the SVG to (stdout if not specified)
34
+ -e, --export-format <format> 'svg' or 'json' (default: "svg")
35
+ -l, --load-timeout <milliseconds> Timeout for loading the board in milliseconds (default: 15000)
35
36
  -h, --help display help for command
36
37
  ```
37
38
 
@@ -116,7 +117,7 @@ const svgOfFrame1 = await miroBoard.getSvg([framesWithTitleFrame1[0].id]);
116
117
  > [!WARNING]
117
118
  > Remember to dispose the instance to make sure the browser is closed and the process
118
119
  > can exit. `await using` (as shown above) does this automatically, but is not supported
119
- > in all environments and may not be the optimal choise in every case. Alternatively,
120
+ > in all environments and may not be the optimal choice in every case. Alternatively,
120
121
  > `miroBoard.dispose()` may be called at any time to dispose of the instance manually.
121
122
 
122
123
  Types for many of the common board object types has been provided in [miro-types.ts](src/miro-types.ts).
package/build/cli.js CHANGED
@@ -54,69 +54,85 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
54
54
  import { writeFile } from "fs/promises";
55
55
  import { program } from "@commander-js/extra-typings";
56
56
  import { MiroBoard } from "./index.js";
57
- const { token, boardId, frameNames, outputFile, exportFormat } = program
57
+ const { token, boardId, frameNames, outputFile, exportFormat, loadTimeout } = program
58
58
  .option("-t, --token <token>", "Miro token")
59
59
  .requiredOption("-b, --board-id <boardId>", "The board ID")
60
60
  .option("-f, --frame-names <frameNames...>", "The frame name(s), leave empty to export entire board")
61
- .option("-o, --output-file <filename>", "A file to output the SVG to (stdout if not supplied)")
62
- .option("-e, --export-format <format>", "'svg' or 'json' (default: 'svg')")
61
+ .option("-o, --output-file <filename>", "A file to output the SVG to (stdout if not specified)")
62
+ .option("-e, --export-format <format>", "'svg' or 'json'", "svg")
63
+ .option("-l, --load-timeout <milliseconds>", "Timeout for loading the board in milliseconds", (arg) => parseInt(arg, 10), 15000)
63
64
  .parse()
64
65
  .opts();
65
66
  (async () => {
66
- const env_1 = { stack: [], error: void 0, hasError: false };
67
67
  try {
68
- const miroBoard = __addDisposableResource(env_1, new MiroBoard({ token, boardId }), true);
69
- async function getFrames(frameNames) {
70
- const frames = await miroBoard.getBoardObjects({ type: "frame" }, { title: frameNames });
71
- if (frames && frames.length !== frameNames.length) {
72
- throw Error(`${frameNames.length - frames.length} frame(s) could not be found on the board.`);
68
+ const env_1 = { stack: [], error: void 0, hasError: false };
69
+ try {
70
+ const miroBoard = __addDisposableResource(env_1, new MiroBoard({
71
+ token,
72
+ boardId,
73
+ boardLoadTimeoutMs: loadTimeout
74
+ }), true);
75
+ async function getFrames(frameNames) {
76
+ const frames = await miroBoard.getBoardObjects({ type: "frame" }, { title: frameNames });
77
+ if (frames && frames.length !== frameNames.length) {
78
+ throw Error(`${frameNames.length - frames.length} frame(s) could not be found on the board.`);
79
+ }
80
+ return frames;
73
81
  }
74
- return frames;
75
- }
76
- async function getSvg(frames) {
77
- return await miroBoard.getSvg(frames?.map(({ id }) => id).filter((id) => !!id));
78
- }
79
- async function getJson(frames) {
80
- if (frames) {
81
- const frameChildren = await miroBoard.getBoardObjects({
82
- id: frames.flatMap((frame) => frame.childrenIds)
83
- });
84
- const groupChildren = await miroBoard.getBoardObjects({
85
- id: frameChildren
86
- .filter((child) => child.type === "group")
87
- .flatMap((child) => child.itemsIds)
88
- });
89
- return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
82
+ async function getSvg(frames) {
83
+ return await miroBoard.getSvg(frames?.map(({ id }) => id).filter((id) => !!id));
90
84
  }
91
- return JSON.stringify(await miroBoard.getBoardObjects({}));
92
- }
93
- const getFn = exportFormat === "json" ? getJson : getSvg;
94
- if (outputFile?.includes("{frameName}")) {
95
- if (!frameNames) {
96
- throw Error("Expected frame names to be given when the output file name format expects a frame name.");
97
- }
98
- for (const frameName of frameNames) {
99
- const output = await getFn(await getFrames([frameName]));
100
- await writeFile(outputFile.replace("{frameName}", frameName), output);
85
+ async function getJson(frames) {
86
+ if (frames) {
87
+ const frameChildren = await miroBoard.getBoardObjects({
88
+ id: frames.flatMap((frame) => frame.childrenIds)
89
+ });
90
+ const groupChildren = await miroBoard.getBoardObjects({
91
+ id: frameChildren
92
+ .filter((child) => child.type === "group")
93
+ .flatMap((child) => child.itemsIds)
94
+ });
95
+ return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
96
+ }
97
+ return JSON.stringify(await miroBoard.getBoardObjects({}));
101
98
  }
102
- }
103
- else {
104
- const svg = await getFn(frameNames && (await getFrames(frameNames)));
105
- if (outputFile) {
106
- await writeFile(outputFile, svg);
99
+ const getFn = exportFormat === "json" ? getJson : getSvg;
100
+ if (outputFile?.includes("{frameName}")) {
101
+ if (!frameNames) {
102
+ throw Error("Expected frame names to be given when the output file name format expects a frame name.");
103
+ }
104
+ for (const frameName of frameNames) {
105
+ const output = await getFn(await getFrames([frameName]));
106
+ await writeFile(outputFile.replace("{frameName}", frameName), output);
107
+ }
107
108
  }
108
109
  else {
109
- process.stdout.write(svg);
110
+ const svg = await getFn(frameNames && (await getFrames(frameNames)));
111
+ if (outputFile) {
112
+ await writeFile(outputFile, svg);
113
+ }
114
+ else {
115
+ process.stdout.write(svg);
116
+ }
110
117
  }
111
118
  }
119
+ catch (e_1) {
120
+ env_1.error = e_1;
121
+ env_1.hasError = true;
122
+ }
123
+ finally {
124
+ const result_1 = __disposeResources(env_1);
125
+ if (result_1)
126
+ await result_1;
127
+ }
112
128
  }
113
- catch (e_1) {
114
- env_1.error = e_1;
115
- env_1.hasError = true;
116
- }
117
- finally {
118
- const result_1 = __disposeResources(env_1);
119
- if (result_1)
120
- await result_1;
129
+ catch (err) {
130
+ const RED = "\x1b[31m";
131
+ const RESET = "\x1b[0m";
132
+ const GRAY = "\x1b[38;5;248m";
133
+ console.error(`❌ ${RED}Error:${RESET} ${err instanceof Error ? err.message : err}`);
134
+ console.error(GRAY);
135
+ console.error(err);
136
+ console.error(RESET);
121
137
  }
122
138
  })();
package/build/index.d.ts CHANGED
@@ -1,8 +1,27 @@
1
+ import { type LaunchOptions } from "puppeteer";
1
2
  import type { BoardObject } from "./miro-types.ts";
2
3
  import type { GetBoardsFilter } from "./miro-runtime.ts";
3
4
  interface InitialMiroBoardOptions {
5
+ /**
6
+ * The Miro authentication token with access to load the board.
7
+ * Optional if anonymous users may access the board without
8
+ * logging in.
9
+ */
4
10
  token?: string;
11
+ /**
12
+ * The Miro board ID.
13
+ */
5
14
  boardId: string;
15
+ /**
16
+ * Optional custom Puppeteer launch options.
17
+ */
18
+ puppeteerOptions?: LaunchOptions;
19
+ /**
20
+ * Timeout until it is determined that the Miro board could
21
+ * not be loaded for some reason, in milliseconds. Default
22
+ * is 15 seconds (15,000 milliseconds).
23
+ */
24
+ boardLoadTimeoutMs?: number;
6
25
  }
7
26
  type AdditionalFilter<T> = Partial<T> | Partial<{
8
27
  [K in keyof T]: T[K][];
@@ -12,12 +31,19 @@ type FilteredResultsByType<F extends string | string[] | undefined, T> = F exten
12
31
  }>[] : T[];
13
32
  export declare class MiroBoard {
14
33
  private context;
34
+ private isDisposed;
15
35
  constructor(options: InitialMiroBoardOptions);
16
36
  private initialize;
17
37
  dispose(): Promise<void>;
18
38
  [Symbol.asyncDispose](): Promise<void>;
19
39
  private get browser();
20
40
  private get page();
41
+ /**
42
+ * Checks that all widgets have been loaded. Times out to 5 seconds due to bug
43
+ * in Miro where certain boards fail to load all widgets if SDK is not required
44
+ * for this function call.
45
+ */
46
+ private checkAllWidgetsLoaded;
21
47
  getBoardObjects<F extends GetBoardsFilter>(filter: F, additionalFilter?: AdditionalFilter<BoardObject>): Promise<FilteredResultsByType<F["type"], BoardObject>>;
22
48
  getSvg(objectsIds?: string[]): Promise<string>;
23
49
  }
package/build/index.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import puppeteer, { Browser, Page } from "puppeteer";
2
+ const DEFAULT_BOARD_LOAD_TIMEOUT_MS = 15_000;
2
3
  export class MiroBoard {
3
4
  context = Promise.withResolvers();
5
+ isDisposed = false;
4
6
  constructor(options) {
5
7
  this.initialize(options);
6
8
  }
7
9
  async initialize(options) {
8
- const browser = await puppeteer.launch({ headless: true });
10
+ const browser = await puppeteer.launch({
11
+ headless: true,
12
+ ...(options.puppeteerOptions ?? {})
13
+ });
9
14
  const page = await browser.newPage();
10
15
  if (options.token) {
11
16
  await browser.browserContexts()[0].setCookie({
@@ -23,24 +28,41 @@ export class MiroBoard {
23
28
  await page.goto(`https://miro.com/app/board/${options.boardId}/`, {
24
29
  waitUntil: "domcontentloaded"
25
30
  });
26
- await page.evaluate(() => new Promise((resolve) => {
27
- if (window.miro) {
28
- resolve();
29
- }
30
- let miroValue;
31
- Object.defineProperty(window, "miro", {
32
- get() {
33
- return miroValue;
34
- },
35
- set(value) {
36
- miroValue = value;
31
+ try {
32
+ await page.evaluate((timeoutDuration) => new Promise((resolve, reject) => {
33
+ if (window.cmd?.board?.api) {
37
34
  resolve();
38
35
  }
39
- });
40
- }));
36
+ const timeout = setTimeout(() => {
37
+ reject(new Error(`Miro board could not be loaded: application instance not available after ${timeoutDuration} ms. Check your network connection, access token and board access.`));
38
+ }, timeoutDuration);
39
+ const interval = setInterval(() => {
40
+ if (document.querySelector('[data-testid="signup-popup-container"]')) {
41
+ clearInterval(interval);
42
+ clearTimeout(timeout);
43
+ reject(new Error(`Miro board requires authentication. Check board access settings to allow anonymous access or supply a token.`));
44
+ }
45
+ else if (window.cmd?.board?.api) {
46
+ clearTimeout(timeout);
47
+ clearInterval(interval);
48
+ resolve();
49
+ }
50
+ }, 250);
51
+ }), options.boardLoadTimeoutMs ?? DEFAULT_BOARD_LOAD_TIMEOUT_MS);
52
+ }
53
+ catch (err) {
54
+ await browser.close();
55
+ this.context.reject(err);
56
+ this.isDisposed = true;
57
+ return;
58
+ }
41
59
  this.context.resolve({ browser, page });
42
60
  }
43
61
  async dispose() {
62
+ if (this.isDisposed) {
63
+ return;
64
+ }
65
+ this.isDisposed = true;
44
66
  await (await this.browser).close();
45
67
  }
46
68
  async [Symbol.asyncDispose]() {
@@ -52,7 +74,34 @@ export class MiroBoard {
52
74
  get page() {
53
75
  return this.context.promise.then(({ page }) => page);
54
76
  }
77
+ /**
78
+ * Checks that all widgets have been loaded. Times out to 5 seconds due to bug
79
+ * in Miro where certain boards fail to load all widgets if SDK is not required
80
+ * for this function call.
81
+ */
82
+ async checkAllWidgetsLoaded(mustHaveSdk = false) {
83
+ await (await this.page).evaluate((mustHaveSdk, timeoutDuration) => new Promise((resolve, reject) => {
84
+ const startTime = Date.now();
85
+ const interval = setInterval(() => {
86
+ if (window.cmd?.board?.api?.isAllWidgetsLoaded() &&
87
+ (!mustHaveSdk || window.miro)) {
88
+ clearInterval(interval);
89
+ resolve();
90
+ }
91
+ if (Date.now() - startTime >= timeoutDuration) {
92
+ clearInterval(interval);
93
+ if (mustHaveSdk) {
94
+ reject(new Error(`Miro SDK failed to load in ${timeoutDuration} ms. This is likely caused by a Miro-internal issue. Check that the board is accessible using an incognito browser window.`));
95
+ }
96
+ else {
97
+ resolve();
98
+ }
99
+ }
100
+ }, 150);
101
+ }), mustHaveSdk, 3_000);
102
+ }
55
103
  async getBoardObjects(filter, additionalFilter) {
104
+ await this.checkAllWidgetsLoaded(true);
56
105
  return (await this.page).evaluate(async (filter, additionalFilter) => {
57
106
  // @ts-expect-error - https://github.com/evanw/esbuild/issues/2605#issuecomment-2050808084
58
107
  window.__name = (func) => func;
@@ -81,8 +130,9 @@ export class MiroBoard {
81
130
  }, filter, additionalFilter);
82
131
  }
83
132
  async getSvg(objectsIds) {
133
+ await this.checkAllWidgetsLoaded(objectsIds !== undefined);
84
134
  return (await this.page).evaluate(async (objectsIds) => {
85
- await window.miro.board.deselect();
135
+ window.cmd.board.api.clearSelection();
86
136
  if (objectsIds) {
87
137
  for (const id of objectsIds) {
88
138
  await window.miro.board.select({ id });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miro-export",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "author": "jolle <npm-contact@jolle.io>",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,25 +23,25 @@
23
23
  "miro-export": "./build/cli.js"
24
24
  },
25
25
  "devDependencies": {
26
- "@eslint/eslintrc": "^3.2.0",
27
- "@eslint/js": "^9.17.0",
28
- "@stylistic/eslint-plugin-ts": "^2.12.1",
29
- "@types/node": "^22.10.2",
26
+ "@eslint/eslintrc": "^3.3.3",
27
+ "@eslint/js": "^9.39.2",
28
+ "@stylistic/eslint-plugin-ts": "^4.4.1",
29
+ "@types/node": "^25.0.3",
30
30
  "ajv": "^8.17.1",
31
- "eslint": "^9.17.0",
32
- "eslint-config-prettier": "^9.1.0",
33
- "eslint-plugin-prettier": "^5.2.1",
31
+ "eslint": "^9.39.2",
32
+ "eslint-config-prettier": "^10.1.8",
33
+ "eslint-plugin-prettier": "^5.5.4",
34
34
  "eslint-plugin-promise": "^7.2.1",
35
- "prettier": "^3.4.2",
36
- "ts-json-schema-generator": "^2.3.0",
37
- "tsx": "^4.19.2",
38
- "typescript": "^5.7.2",
39
- "typescript-eslint": "^8.18.1"
35
+ "prettier": "^3.7.4",
36
+ "ts-json-schema-generator": "^2.4.0",
37
+ "tsx": "^4.21.0",
38
+ "typescript": "^5.9.3",
39
+ "typescript-eslint": "^8.50.1"
40
40
  },
41
41
  "dependencies": {
42
- "@commander-js/extra-typings": "^12.1.0",
43
- "commander": "^12.1.0",
44
- "puppeteer": "^23.11.1"
42
+ "@commander-js/extra-typings": "^14.0.0",
43
+ "commander": "^14.0.2",
44
+ "puppeteer": "^24.34.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22.0.0"
package/src/cli.ts CHANGED
@@ -3,84 +3,107 @@ import { program } from "@commander-js/extra-typings";
3
3
  import { MiroBoard } from "./index.js";
4
4
  import type { FrameBoardObject } from "./miro-types.ts";
5
5
 
6
- const { token, boardId, frameNames, outputFile, exportFormat } = program
7
- .option("-t, --token <token>", "Miro token")
8
- .requiredOption("-b, --board-id <boardId>", "The board ID")
9
- .option(
10
- "-f, --frame-names <frameNames...>",
11
- "The frame name(s), leave empty to export entire board"
12
- )
13
- .option(
14
- "-o, --output-file <filename>",
15
- "A file to output the SVG to (stdout if not supplied)"
16
- )
17
- .option("-e, --export-format <format>", "'svg' or 'json' (default: 'svg')")
18
- .parse()
19
- .opts();
6
+ const { token, boardId, frameNames, outputFile, exportFormat, loadTimeout } =
7
+ program
8
+ .option("-t, --token <token>", "Miro token")
9
+ .requiredOption("-b, --board-id <boardId>", "The board ID")
10
+ .option(
11
+ "-f, --frame-names <frameNames...>",
12
+ "The frame name(s), leave empty to export entire board"
13
+ )
14
+ .option(
15
+ "-o, --output-file <filename>",
16
+ "A file to output the SVG to (stdout if not specified)"
17
+ )
18
+ .option("-e, --export-format <format>", "'svg' or 'json'", "svg")
19
+ .option(
20
+ "-l, --load-timeout <milliseconds>",
21
+ "Timeout for loading the board in milliseconds",
22
+ (arg) => parseInt(arg, 10),
23
+ 15000
24
+ )
25
+ .parse()
26
+ .opts();
20
27
 
21
28
  (async () => {
22
- await using miroBoard = new MiroBoard({ token, boardId });
29
+ try {
30
+ await using miroBoard = new MiroBoard({
31
+ token,
32
+ boardId,
33
+ boardLoadTimeoutMs: loadTimeout
34
+ });
23
35
 
24
- async function getFrames(frameNames: string[]) {
25
- const frames = await miroBoard.getBoardObjects(
26
- { type: "frame" as const },
27
- { title: frameNames }
28
- );
36
+ async function getFrames(frameNames: string[]) {
37
+ const frames = await miroBoard.getBoardObjects(
38
+ { type: "frame" as const },
39
+ { title: frameNames }
40
+ );
41
+
42
+ if (frames && frames.length !== frameNames.length) {
43
+ throw Error(
44
+ `${
45
+ frameNames.length - frames.length
46
+ } frame(s) could not be found on the board.`
47
+ );
48
+ }
49
+
50
+ return frames;
51
+ }
29
52
 
30
- if (frames && frames.length !== frameNames.length) {
31
- throw Error(
32
- `${
33
- frameNames.length - frames.length
34
- } frame(s) could not be found on the board.`
53
+ async function getSvg(frames?: FrameBoardObject[]) {
54
+ return await miroBoard.getSvg(
55
+ frames?.map(({ id }) => id).filter((id): id is string => !!id)
35
56
  );
36
57
  }
37
58
 
38
- return frames;
39
- }
59
+ async function getJson(frames?: FrameBoardObject[]) {
60
+ if (frames) {
61
+ const frameChildren = await miroBoard.getBoardObjects({
62
+ id: frames.flatMap((frame) => frame.childrenIds)
63
+ });
40
64
 
41
- async function getSvg(frames?: FrameBoardObject[]) {
42
- return await miroBoard.getSvg(
43
- frames?.map(({ id }) => id).filter((id): id is string => !!id)
44
- );
45
- }
65
+ const groupChildren = await miroBoard.getBoardObjects({
66
+ id: frameChildren
67
+ .filter((child) => child.type === "group")
68
+ .flatMap((child) => child.itemsIds)
69
+ });
46
70
 
47
- async function getJson(frames?: FrameBoardObject[]) {
48
- if (frames) {
49
- const frameChildren = await miroBoard.getBoardObjects({
50
- id: frames.flatMap((frame) => frame.childrenIds)
51
- });
71
+ return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
72
+ }
52
73
 
53
- const groupChildren = await miroBoard.getBoardObjects({
54
- id: frameChildren
55
- .filter((child) => child.type === "group")
56
- .flatMap((child) => child.itemsIds)
57
- });
58
-
59
- return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
74
+ return JSON.stringify(await miroBoard.getBoardObjects({}));
60
75
  }
61
76
 
62
- return JSON.stringify(await miroBoard.getBoardObjects({}));
63
- }
77
+ const getFn = exportFormat === "json" ? getJson : getSvg;
64
78
 
65
- const getFn = exportFormat === "json" ? getJson : getSvg;
79
+ if (outputFile?.includes("{frameName}")) {
80
+ if (!frameNames) {
81
+ throw Error(
82
+ "Expected frame names to be given when the output file name format expects a frame name."
83
+ );
84
+ }
66
85
 
67
- if (outputFile?.includes("{frameName}")) {
68
- if (!frameNames) {
69
- throw Error(
70
- "Expected frame names to be given when the output file name format expects a frame name."
71
- );
72
- }
73
-
74
- for (const frameName of frameNames) {
75
- const output = await getFn(await getFrames([frameName]));
76
- await writeFile(outputFile.replace("{frameName}", frameName), output);
77
- }
78
- } else {
79
- const svg = await getFn(frameNames && (await getFrames(frameNames)));
80
- if (outputFile) {
81
- await writeFile(outputFile, svg);
86
+ for (const frameName of frameNames) {
87
+ const output = await getFn(await getFrames([frameName]));
88
+ await writeFile(outputFile.replace("{frameName}", frameName), output);
89
+ }
82
90
  } else {
83
- process.stdout.write(svg);
91
+ const svg = await getFn(frameNames && (await getFrames(frameNames)));
92
+ if (outputFile) {
93
+ await writeFile(outputFile, svg);
94
+ } else {
95
+ process.stdout.write(svg);
96
+ }
84
97
  }
98
+ } catch (err) {
99
+ const RED = "\x1b[31m";
100
+ const RESET = "\x1b[0m";
101
+ const GRAY = "\x1b[38;5;248m";
102
+ console.error(
103
+ `❌ ${RED}Error:${RESET} ${err instanceof Error ? err.message : err}`
104
+ );
105
+ console.error(GRAY);
106
+ console.error(err);
107
+ console.error(RESET);
85
108
  }
86
109
  })();
package/src/index.ts CHANGED
@@ -1,12 +1,32 @@
1
- import puppeteer, { Browser, Page } from "puppeteer";
1
+ import puppeteer, { Browser, Page, type LaunchOptions } from "puppeteer";
2
2
  import type { BoardObject } from "./miro-types.ts";
3
3
  import type { GetBoardsFilter } from "./miro-runtime.ts";
4
4
 
5
5
  interface InitialMiroBoardOptions {
6
+ /**
7
+ * The Miro authentication token with access to load the board.
8
+ * Optional if anonymous users may access the board without
9
+ * logging in.
10
+ */
6
11
  token?: string;
12
+ /**
13
+ * The Miro board ID.
14
+ */
7
15
  boardId: string;
16
+ /**
17
+ * Optional custom Puppeteer launch options.
18
+ */
19
+ puppeteerOptions?: LaunchOptions;
20
+ /**
21
+ * Timeout until it is determined that the Miro board could
22
+ * not be loaded for some reason, in milliseconds. Default
23
+ * is 15 seconds (15,000 milliseconds).
24
+ */
25
+ boardLoadTimeoutMs?: number;
8
26
  }
9
27
 
28
+ const DEFAULT_BOARD_LOAD_TIMEOUT_MS = 15_000;
29
+
10
30
  type AdditionalFilter<T> = Partial<T> | Partial<{ [K in keyof T]: T[K][] }>;
11
31
 
12
32
  type FilteredResultsByType<
@@ -16,13 +36,17 @@ type FilteredResultsByType<
16
36
 
17
37
  export class MiroBoard {
18
38
  private context = Promise.withResolvers<{ browser: Browser; page: Page }>();
39
+ private isDisposed = false;
19
40
 
20
41
  constructor(options: InitialMiroBoardOptions) {
21
42
  this.initialize(options);
22
43
  }
23
44
 
24
45
  private async initialize(options: InitialMiroBoardOptions) {
25
- const browser = await puppeteer.launch({ headless: true });
46
+ const browser = await puppeteer.launch({
47
+ headless: true,
48
+ ...(options.puppeteerOptions ?? {})
49
+ });
26
50
  const page = await browser.newPage();
27
51
 
28
52
  if (options.token) {
@@ -44,30 +68,57 @@ export class MiroBoard {
44
68
  waitUntil: "domcontentloaded"
45
69
  });
46
70
 
47
- await page.evaluate(
48
- () =>
49
- new Promise<void>((resolve) => {
50
- if (window.miro) {
51
- resolve();
52
- }
53
-
54
- let miroValue: (typeof window)["miro"];
55
- Object.defineProperty(window, "miro", {
56
- get() {
57
- return miroValue;
58
- },
59
- set(value) {
60
- miroValue = value;
71
+ try {
72
+ await page.evaluate(
73
+ (timeoutDuration) =>
74
+ new Promise<void>((resolve, reject) => {
75
+ if (window.cmd?.board?.api) {
61
76
  resolve();
62
77
  }
63
- });
64
- })
65
- );
78
+
79
+ const timeout = setTimeout(() => {
80
+ reject(
81
+ new Error(
82
+ `Miro board could not be loaded: application instance not available after ${timeoutDuration} ms. Check your network connection, access token and board access.`
83
+ )
84
+ );
85
+ }, timeoutDuration);
86
+
87
+ const interval = setInterval(() => {
88
+ if (
89
+ document.querySelector('[data-testid="signup-popup-container"]')
90
+ ) {
91
+ clearInterval(interval);
92
+ clearTimeout(timeout);
93
+ reject(
94
+ new Error(
95
+ `Miro board requires authentication. Check board access settings to allow anonymous access or supply a token.`
96
+ )
97
+ );
98
+ } else if (window.cmd?.board?.api) {
99
+ clearTimeout(timeout);
100
+ clearInterval(interval);
101
+ resolve();
102
+ }
103
+ }, 250);
104
+ }),
105
+ options.boardLoadTimeoutMs ?? DEFAULT_BOARD_LOAD_TIMEOUT_MS
106
+ );
107
+ } catch (err) {
108
+ await browser.close();
109
+ this.context.reject(err);
110
+ this.isDisposed = true;
111
+ return;
112
+ }
66
113
 
67
114
  this.context.resolve({ browser, page });
68
115
  }
69
116
 
70
117
  async dispose() {
118
+ if (this.isDisposed) {
119
+ return;
120
+ }
121
+ this.isDisposed = true;
71
122
  await (await this.browser).close();
72
123
  }
73
124
 
@@ -83,10 +134,52 @@ export class MiroBoard {
83
134
  return this.context.promise.then(({ page }) => page);
84
135
  }
85
136
 
137
+ /**
138
+ * Checks that all widgets have been loaded. Times out to 5 seconds due to bug
139
+ * in Miro where certain boards fail to load all widgets if SDK is not required
140
+ * for this function call.
141
+ */
142
+ private async checkAllWidgetsLoaded(mustHaveSdk: boolean = false) {
143
+ await (
144
+ await this.page
145
+ ).evaluate(
146
+ (mustHaveSdk, timeoutDuration) =>
147
+ new Promise<void>((resolve, reject) => {
148
+ const startTime = Date.now();
149
+ const interval = setInterval(() => {
150
+ if (
151
+ window.cmd?.board?.api?.isAllWidgetsLoaded() &&
152
+ (!mustHaveSdk || window.miro)
153
+ ) {
154
+ clearInterval(interval);
155
+ resolve();
156
+ }
157
+
158
+ if (Date.now() - startTime >= timeoutDuration) {
159
+ clearInterval(interval);
160
+ if (mustHaveSdk) {
161
+ reject(
162
+ new Error(
163
+ `Miro SDK failed to load in ${timeoutDuration} ms. This is likely caused by a Miro-internal issue. Check that the board is accessible using an incognito browser window.`
164
+ )
165
+ );
166
+ } else {
167
+ resolve();
168
+ }
169
+ }
170
+ }, 150);
171
+ }),
172
+ mustHaveSdk,
173
+ 3_000
174
+ );
175
+ }
176
+
86
177
  async getBoardObjects<F extends GetBoardsFilter>(
87
178
  filter: F,
88
179
  additionalFilter?: AdditionalFilter<BoardObject>
89
180
  ): Promise<FilteredResultsByType<F["type"], BoardObject>> {
181
+ await this.checkAllWidgetsLoaded(true);
182
+
90
183
  return (await this.page).evaluate(
91
184
  async (filter, additionalFilter) => {
92
185
  // @ts-expect-error - https://github.com/evanw/esbuild/issues/2605#issuecomment-2050808084
@@ -130,8 +223,10 @@ export class MiroBoard {
130
223
  }
131
224
 
132
225
  async getSvg(objectsIds?: string[]) {
226
+ await this.checkAllWidgetsLoaded(objectsIds !== undefined);
227
+
133
228
  return (await this.page).evaluate(async (objectsIds) => {
134
- await window.miro.board.deselect();
229
+ window.cmd.board.api.clearSelection();
135
230
 
136
231
  if (objectsIds) {
137
232
  for (const id of objectsIds) {
@@ -21,6 +21,8 @@ declare global {
21
21
  export: {
22
22
  makeVector: () => Promise<string>;
23
23
  };
24
+ clearSelection: () => void;
25
+ isAllWidgetsLoaded: () => boolean;
24
26
  };
25
27
  };
26
28
  };
package/src/miro-types.ts CHANGED
@@ -69,15 +69,14 @@ interface BoardObjectWithDimensions {
69
69
  }
70
70
 
71
71
  export interface PreviewBoardObject
72
- extends BoardItemBase,
73
- BoardObjectBase,
74
- BoardObjectWithDimensions {
72
+ extends BoardItemBase, BoardObjectBase, BoardObjectWithDimensions {
75
73
  type: "preview";
76
74
  url: string;
77
75
  }
78
76
 
79
77
  export interface FrameBoardObject
80
- extends Omit<BoardItemBase, "connectorIds">,
78
+ extends
79
+ Omit<BoardItemBase, "connectorIds">,
81
80
  BoardObjectBase,
82
81
  BoardObjectWithDimensions,
83
82
  BoardObjectWithCoordinates {
@@ -96,7 +95,8 @@ export interface GroupBoardItem extends BoardItemBase {
96
95
  }
97
96
 
98
97
  export interface StickyNoteBoardObject
99
- extends BoardItemBase,
98
+ extends
99
+ BoardItemBase,
100
100
  BoardObjectBase,
101
101
  BoardObjectWithCoordinates,
102
102
  BoardObjectWithDimensions {
@@ -112,7 +112,8 @@ export interface StickyNoteBoardObject
112
112
  }
113
113
 
114
114
  export interface TextBoardObject
115
- extends BoardItemBase,
115
+ extends
116
+ BoardItemBase,
116
117
  BoardObjectBase,
117
118
  BoardObjectWithCoordinates,
118
119
  BoardObjectWithDimensions {
@@ -130,7 +131,8 @@ export interface TextBoardObject
130
131
  }
131
132
 
132
133
  export interface ImageBoardObject
133
- extends BoardItemBase,
134
+ extends
135
+ BoardItemBase,
134
136
  BoardObjectBase,
135
137
  BoardObjectWithDimensions,
136
138
  BoardObjectWithCoordinates {
@@ -159,9 +161,7 @@ interface TableStyle {
159
161
  }
160
162
 
161
163
  export interface TableBoardObject
162
- extends BoardItemBase,
163
- BoardObjectWithDimensions,
164
- BoardObjectWithCoordinates {
164
+ extends BoardItemBase, BoardObjectWithDimensions, BoardObjectWithCoordinates {
165
165
  type: "image";
166
166
  rotation: number;
167
167
  cols?: number | { width: number }[];
@@ -176,7 +176,8 @@ export interface TableBoardObject
176
176
  }
177
177
 
178
178
  export interface StructDocBoardObject
179
- extends BoardItemBase,
179
+ extends
180
+ BoardItemBase,
180
181
  BoardObjectBase,
181
182
  BoardObjectWithDimensions,
182
183
  BoardObjectWithCoordinates {
@@ -186,9 +187,7 @@ export interface StructDocBoardObject
186
187
  }
187
188
 
188
189
  export interface EmbedBoardObject
189
- extends BoardItemBase,
190
- BoardObjectBase,
191
- BoardObjectWithCoordinates {
190
+ extends BoardItemBase, BoardObjectBase, BoardObjectWithCoordinates {
192
191
  type: "struct_doc";
193
192
  url: string;
194
193
  previewUrl: string;
@@ -198,7 +197,8 @@ export interface EmbedBoardObject
198
197
  }
199
198
 
200
199
  export interface ShapeBoardObject
201
- extends BoardItemBase,
200
+ extends
201
+ BoardItemBase,
202
202
  BoardObjectBase,
203
203
  BoardObjectWithCoordinates,
204
204
  BoardObjectWithDimensions {
@@ -265,7 +265,8 @@ export interface TagBoardItem extends BoardItemBase {
265
265
  }
266
266
 
267
267
  export interface KanbanBoardObject
268
- extends BoardItemBase,
268
+ extends
269
+ BoardItemBase,
269
270
  BoardObjectBase,
270
271
  BoardObjectWithCoordinates,
271
272
  BoardObjectWithDimensions {
@@ -273,7 +274,8 @@ export interface KanbanBoardObject
273
274
  }
274
275
 
275
276
  export interface UnsupportedBoardObject
276
- extends BoardItemBase,
277
+ extends
278
+ BoardItemBase,
277
279
  BoardObjectBase,
278
280
  BoardObjectWithCoordinates,
279
281
  BoardObjectWithDimensions {
package/tests/api.test.ts CHANGED
@@ -3,12 +3,24 @@ import assert from "node:assert";
3
3
  import { MiroBoard } from "../src";
4
4
 
5
5
  const boardId = process.env.TEST_BOARD_ID;
6
+ const inaccessibleBoardId = process.env.PRIVATE_TEST_BOARD_ID;
7
+ const buggyBoardId = process.env.BUGGY_TEST_BOARD_ID;
6
8
 
7
9
  if (!boardId) {
8
10
  console.error("TEST_BOARD_ID environment variable is required.");
9
11
  process.exit(1);
10
12
  }
11
13
 
14
+ if (!inaccessibleBoardId) {
15
+ console.error("PRIVATE_TEST_BOARD_ID environment variable is required.");
16
+ process.exit(1);
17
+ }
18
+
19
+ if (!buggyBoardId) {
20
+ console.error("BUGGY_TEST_BOARD_ID environment variable is required.");
21
+ process.exit(1);
22
+ }
23
+
12
24
  describe("Miro integration", async () => {
13
25
  const miroBoard = new MiroBoard({ boardId });
14
26
 
@@ -66,3 +78,32 @@ describe("Miro integration", async () => {
66
78
  assert.ok(svg.length > 10_000);
67
79
  });
68
80
  });
81
+
82
+ await it("should throw error for a non-public board", async () => {
83
+ try {
84
+ const miroBoard = new MiroBoard({ boardId: inaccessibleBoardId });
85
+ await miroBoard.getBoardObjects({});
86
+ assert.fail();
87
+ } catch (err) {
88
+ assert.ok(err instanceof Error);
89
+ assert.equal(
90
+ err.message,
91
+ "Miro board requires authentication. Check board access settings to allow anonymous access or supply a token."
92
+ );
93
+ }
94
+ });
95
+
96
+ await it("should be able to export SVG of a buggy Miro board", async () => {
97
+ // this is a flaky issue on Miro; around 10% of the time the board loads fine
98
+ // so let's repeat this test a couple of times just to be sure
99
+ const results = await Promise.all(
100
+ Array.from({ length: 10 }).map(async () => {
101
+ const miroBoard = new MiroBoard({ boardId: buggyBoardId });
102
+ const svg = await miroBoard.getSvg();
103
+ const hasSvgContents = svg.includes("What should we do next?");
104
+ await miroBoard.dispose();
105
+ return hasSvgContents;
106
+ })
107
+ );
108
+ assert.deepStrictEqual(results, Array.from({ length: 10 }).fill(true));
109
+ });