├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── examples ├── embed-project-vm │ ├── index.html │ ├── index.ts │ └── styles.css ├── index.html ├── open-embed-github-project │ ├── index.html │ ├── index.ts │ ├── package.json │ └── styles.css ├── open-embed-project-id │ ├── index.html │ ├── index.ts │ ├── package.json │ └── styles.css └── open-embed-webcontainer │ ├── index.html │ ├── index.ts │ ├── package.json │ └── styles.css ├── package-lock.json ├── package.json ├── playwright.config.ts ├── src ├── connection.ts ├── constants.ts ├── generate.ts ├── helpers.ts ├── index.ts ├── interfaces.ts ├── lib.ts ├── params.ts ├── rdc.ts └── vm.ts ├── test ├── e2e │ ├── embedProject.spec.ts │ ├── embedVm.spec.ts │ └── openProject.spec.ts ├── embed │ ├── index.html │ ├── index.ts │ └── styles.css ├── env.d.ts ├── pages │ ├── blank.html │ └── index.ts ├── server │ ├── handlers │ │ ├── dependencies.ts │ │ ├── editor.ts │ │ ├── fs.ts │ │ ├── init.ts │ │ └── preview.ts │ ├── request.ts │ ├── types.ts │ └── validation.ts └── unit │ ├── __snapshots__ │ ├── generate.spec.ts.snap │ ├── lib.spec.ts.snap │ └── vm.spec.ts.snap │ ├── generate.spec.ts │ ├── helpers.spec.ts │ ├── lib.spec.ts │ ├── params.spec.ts │ ├── rdc.spec.ts │ ├── utils │ ├── console.ts │ ├── dom.ts │ └── project.ts │ └── vm.spec.ts ├── tsconfig.json ├── tsconfig.lib.json ├── tsconfig.test.json └── vite.config.ts /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test_unit: 11 | name: Unit tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: { node-version: '18' } 17 | - run: npm ci 18 | - run: npm run test:format 19 | - run: npm run build 20 | - run: npm run test:unit 21 | test_e2e: 22 | name: End-to-end tests 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: { node-version: '18' } 28 | - run: npm ci 29 | - run: npx playwright install chromium --with-deps 30 | - run: npm run test:e2e 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | bundles 3 | types 4 | 5 | # Dependencies 6 | node_modules 7 | 8 | # Tooling artifacts 9 | temp 10 | 11 | # Misc 12 | ._* 13 | .DS_Store 14 | .vscode 15 | *.log 16 | *.tgz 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "endOfLine": "lf", 7 | "semi": true, 8 | "arrowParens": "always", 9 | "bracketSpacing": true, 10 | "trailingComma": "es5" 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @stackblitz/sdk changelog 2 | 3 | ## v1.11.0 (2024-07-02) 4 | 5 | - Add cross-origin isolation flag (https://github.com/stackblitz/sdk/pull/20) 6 | - Fix and test format (https://github.com/stackblitz/sdk/pull/21) 7 | 8 | ## v1.10.0 (2024-05-03) 9 | 10 | - Added support for `organization` in `ProjectOptions` 11 | 12 | ## v1.9.0 (2023-04-04) 13 | 14 | - Moved the StackBlitz SDK to a dedicated repository: https://github.com/stackblitz/sdk. 15 | - Added support for new options: `startScript`, `sidebarView` and `zenMode`. (https://github.com/stackblitz/sdk/pull/4) 16 | - Changed `project.description` to be optional. (https://github.com/stackblitz/sdk/pull/5) 17 | 18 | ## v1.8.2 (2023-01-26) 19 | 20 | - Fixed using the characters `[]` in file paths with the `embedProject` and `openProject` methods. (https://github.com/stackblitz/core/pull/2295) 21 | 22 | ## v1.8.1 (2022-11-10) 23 | 24 | - Fixed the case of the URL query parameters for the `hideDevTools` and `devToolsHeight` options, for backwards compatibility with StackBlitz EE. (https://github.com/stackblitz/core/pull/2154) 25 | 26 | ## v1.8.0 (2022-06-09) 27 | 28 | - Added a `terminalHeight` option, used to set a preferred height for the Terminal in WebContainers-based projects. (https://github.com/stackblitz/core/pull/1891) 29 | 30 | ## v1.7.0 (2022-05-09) 31 | 32 | - TypeScript: improved the precision and inline documentation of types such as `Project`, `EmbedOptions`, `OpenOptions` and `VM`. Made those types directly importable with `import type { Project } from '@stackblitz/sdk'`. (https://github.com/stackblitz/core/pull/1775, https://github.com/stackblitz/core/pull/1779, https://github.com/stackblitz/core/pull/1837) 33 | - Added support for opening multiple files in an embedded projects with the `vm.editor.openFile` method. (https://github.com/stackblitz/core/pull/1810) 34 | - Added new methods to the `VM` class for controlling the embedded editor’s UI: `vm.editor.setCurrentFile`, `vm.editor.setTheme`, `vm.editor.setView`, `vm.editor.showSidebar`, `vm.preview.getUrl`, `vm.preview.setUrl`. (https://github.com/stackblitz/core/pull/1810, https://github.com/stackblitz/core/pull/1837) 35 | - Added new `showSidebar` option. (https://github.com/stackblitz/core/pull/1837) 36 | - Added source maps to the published bundle files. (https://github.com/stackblitz/core/pull/1776) 37 | - Fixed the default value of the `forceEmbedLayout` option. (https://github.com/stackblitz/core/pull/1817) 38 | 39 | ## v1.6.0 (2022-03-02) 40 | 41 | - Add support for opening multiple files with the openFile parameter, with support for multiple tabs (`openFile: 'index.html,src/index.js'`) and split editor panes (`openFile: ['index.html', 'src/index.js]`). (https://github.com/stackblitz/core/pull/1758) 42 | 43 | ## v1.5.6 (2022-02-04) 44 | 45 | - Add `template: 'html'` to the allowed project templates. (https://github.com/stackblitz/core/pull/1728) 46 | 47 | ## v1.5.5 (2022-01-26) 48 | 49 | - Fix broken type declarations in previous v1.5.4. (https://github.com/stackblitz/core/pull/1722) 50 | 51 | ## v1.5.4 (2022-01-20) 52 | 53 | - Add `template: 'node'` to the allowed project templates. (https://github.com/stackblitz/core/pull/1714) 54 | - Remove support for the `tags` option when creating new projects. (https://github.com/stackblitz/core/pull/1714) 55 | 56 | ## v1.5.3 (2021-11-05) 57 | 58 | - Fix: correct type for `EmbedOptions['view']`. (https://github.com/stackblitz/core/pull/1655) 59 | - Fix: set the `EmbedOptions`’s `hideNavigation` UI option correctly. (https://github.com/stackblitz/core/pull/1654) 60 | 61 | ## v1.5.2 (2020-12-07) 62 | 63 | _No known changes._ 64 | 65 | ## v1.5.1 (2020-09-25) 66 | 67 | - Add `template: 'vue'` to the allowed project templates. (https://github.com/stackblitz/core/pull/1307) 68 | 69 | ## v1.5.0 (2020-07-16) 70 | 71 | - Add a `theme` option to `ProjectOptions` to set the editor’s color theme. (https://github.com/stackblitz/core/pull/1269) 72 | 73 | ## v1.4.0 (2020-05-13) 74 | 75 | - Add `origin` option to `ProjectOptions` to allow embedding projects from StackBlitz Enterprise Edition. (https://github.com/stackblitz/core/pull/1236) 76 | 77 | ## v1.3.0 (2019-02-06) 78 | 79 | - Add `template: 'polymer'` to the allowed project templates. (https://github.com/stackblitz/core/pull/859) 80 | 81 | ## v1.2.0 (2018-05-03) 82 | 83 | - Add support for editor UI options: `hideDevTools` and `devToolsHeight`. 84 | - Add support for project compilation settings in `ProjectOptions`. 85 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Eric Simons and Albert Pai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StackBlitz SDK 2 | 3 | The StackBlitz JavaScript SDK lets you programmatically create StackBlitz projects to be opened in a new window or embedded in your docs, example pages, or blog posts. 4 | 5 | ## Documentation 6 | 7 | Check out our SDK documentation on developer.stackblitz.com: 8 | 9 | - [SDK overview](https://developer.stackblitz.com/platform/api/javascript-sdk) 10 | - [Options reference](https://developer.stackblitz.com/platform/api/javascript-sdk-options) 11 | - [Controlling embeds](https://developer.stackblitz.com/platform/api/javascript-sdk-vm) 12 | 13 | ## Reporting issues 14 | 15 | - Issues with the SDK can be filed at https://github.com/stackblitz/sdk/issues 16 | - Other issues with StackBlitz can be filed at https://github.com/stackblitz/core/issues 17 | 18 | ## Development 19 | 20 | We use `npm` and Node 16+. 21 | 22 | ```sh 23 | # Install dependencies 24 | npm install 25 | 26 | # Start a development server to explore examples 27 | npm start 28 | 29 | # Run unit tests 30 | npm test 31 | 32 | # Run end-to-end tests with mock server 33 | npm run test:e2e 34 | 35 | # Run end-to-end tests against stackblitz.com 36 | STACKBLITZ_SERVER_ORIGIN=https://stackblitz.com npm run test:e2e 37 | 38 | # Generate the 'bundles' and 'types' folders 39 | npm run build 40 | ``` 41 | -------------------------------------------------------------------------------- /examples/embed-project-vm/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Control an embedded project with the StackBlitz SDK VM 7 | 8 | 9 | 10 |
11 |

Control an embedded project with the StackBlitz SDK VM

12 | 17 |
18 |
19 |

Embed will go here

20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/embed-project-vm/index.ts: -------------------------------------------------------------------------------- 1 | import sdk, { Project, VM } from '@stackblitz/sdk'; 2 | 3 | import './styles.css'; 4 | 5 | let vm: VM | null = null; 6 | 7 | const project: Project = { 8 | title: 'Dynamically Generated Project', 9 | description: 'Created with <3 by the StackBlitz SDK!', 10 | template: 'javascript', 11 | files: { 12 | 'index.html': `

SDK generated project

`, 13 | 'index.js': '// Hello there!', 14 | }, 15 | settings: { 16 | compile: { clearConsole: false }, 17 | }, 18 | }; 19 | 20 | async function embedNewProject() { 21 | vm = await sdk.embedProject('embed', project, { 22 | openFile: 'index.html', 23 | view: 'editor', 24 | }); 25 | 26 | (window as any).__vm__ = vm; 27 | 28 | // Enable buttons that require the VM 29 | for (const button of document.querySelectorAll('button:disabled')) { 30 | button.disabled = false; 31 | } 32 | } 33 | 34 | async function openFiles() { 35 | if (!vm) { 36 | console.error('SDK vm is not available'); 37 | return; 38 | } 39 | 40 | await vm.editor.openFile(['index.html', 'index.js']); 41 | } 42 | 43 | async function writeToFiles() { 44 | if (!vm) { 45 | console.error('SDK vm is not available'); 46 | return; 47 | } 48 | 49 | const files = (await vm.getFsSnapshot()) ?? {}; 50 | const html = files['index.html'] ?? ''; 51 | const js = files['index.js'] ?? ''; 52 | const time = new Date().toTimeString(); 53 | 54 | await vm.applyFsDiff({ 55 | create: { 56 | 'index.html': `${html}\n`, 57 | 'index.js': `${js}\n// Random content at ${time}`, 58 | }, 59 | destroy: [], 60 | }); 61 | } 62 | 63 | function setup() { 64 | const embedButton = document.querySelector('[name=embed-project]'); 65 | const openFilesButton = document.querySelector('[name=open-files]'); 66 | const writeFilesButton = document.querySelector('[name=write-files]'); 67 | embedButton!.addEventListener('click', embedNewProject); 68 | openFilesButton!.addEventListener('click', openFiles); 69 | writeFilesButton!.addEventListener('click', writeToFiles); 70 | } 71 | 72 | setup(); 73 | -------------------------------------------------------------------------------- /examples/embed-project-vm/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | text-align: center; 4 | font-family: system-ui, sans-serif; 5 | color: black; 6 | background-color: white; 7 | } 8 | 9 | body { 10 | height: 100%; 11 | margin: 0; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | h1 { 17 | margin: 1rem; 18 | font-size: 1.25rem; 19 | } 20 | 21 | nav { 22 | margin: 1rem; 23 | font-size: 0.9rem; 24 | } 25 | 26 | select, 27 | button { 28 | margin: 0.2em; 29 | padding: 0.2em 0.5em; 30 | font-size: inherit; 31 | font-family: inherit; 32 | } 33 | 34 | #embed { 35 | display: flex; 36 | flex: 1 1 60%; 37 | flex-direction: column; 38 | justify-content: center; 39 | overflow: hidden; 40 | width: 100%; 41 | height: auto; 42 | margin: 0; 43 | border: 0; 44 | } 45 | 46 | #embed > p { 47 | width: min(300px, 100%); 48 | margin: 2rem auto; 49 | padding: 4rem 1rem; 50 | border: dashed 2px #ccc; 51 | border-radius: 0.5em; 52 | font-size: 85%; 53 | color: #777; 54 | } 55 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | StackBlitz SDK Examples 8 | 18 | 19 | 20 |

StackBlitz SDK Examples

21 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /examples/open-embed-github-project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | StackBlitz SDK - Open and embed a GitHub repo 7 | 8 | 9 | 10 |
11 |

Open and embed a GitHub repo

12 | 23 |
24 |
25 |

Embed will go here

26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/open-embed-github-project/index.ts: -------------------------------------------------------------------------------- 1 | import sdk from '@stackblitz/sdk'; 2 | 3 | import './styles.css'; 4 | 5 | type Repo = { github: string; openFile: string }; 6 | 7 | const REPOS: Record = { 8 | angular: { 9 | github: 'gothinkster/angular-realworld-example-app', 10 | openFile: 'README.md', 11 | }, 12 | vite: { 13 | github: 'vitejs/vite/tree/main/packages/create-vite/template-vanilla', 14 | openFile: 'index.html', 15 | }, 16 | }; 17 | 18 | let selectedRepo: Repo = REPOS.angular; 19 | 20 | /** 21 | * Embed the project 22 | */ 23 | async function embedProject() { 24 | sdk.embedGithubProject('embed', selectedRepo.github, { 25 | height: 400, 26 | openFile: selectedRepo.openFile, 27 | }); 28 | } 29 | 30 | /** 31 | * Open the project in a new window on StackBlitz 32 | */ 33 | function openProject() { 34 | sdk.openGithubProject(selectedRepo.github, { 35 | openFile: selectedRepo.openFile, 36 | }); 37 | } 38 | 39 | function setRepo(value: string) { 40 | selectedRepo = REPOS[value]; 41 | // if already embedded, update the embed 42 | if (document.getElementById('embed')?.nodeName === 'IFRAME') { 43 | embedProject(); 44 | } 45 | } 46 | 47 | function setup() { 48 | const select = document.querySelector('[name=set-repo]'); 49 | const embedButton = document.querySelector('[name=embed-project]'); 50 | const openButton = document.querySelector('[name=open-project]'); 51 | select!.addEventListener('change', (event) => { 52 | setRepo((event.target as HTMLSelectElement).value); 53 | }); 54 | embedButton!.addEventListener('click', embedProject); 55 | openButton!.addEventListener('click', openProject); 56 | } 57 | 58 | setup(); 59 | -------------------------------------------------------------------------------- /examples/open-embed-github-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sdk-example-open-embed-github-project", 3 | "description": "Demo of the StackBlitz SDK's methods for opening & embedding Github projects.", 4 | "version": "0.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "@stackblitz/sdk": "^1.8.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/open-embed-github-project/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | text-align: center; 4 | font-family: system-ui, sans-serif; 5 | color: black; 6 | background-color: white; 7 | } 8 | 9 | body { 10 | height: 100%; 11 | margin: 0; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | h1 { 17 | margin: 1rem; 18 | font-size: 1.25rem; 19 | } 20 | 21 | nav { 22 | margin: 1rem; 23 | font-size: 0.9rem; 24 | } 25 | 26 | select, 27 | button { 28 | margin: 0.2em; 29 | padding: 0.2em 0.5em; 30 | font-size: inherit; 31 | font-family: inherit; 32 | } 33 | 34 | #embed { 35 | display: flex; 36 | flex: 1 1 60%; 37 | flex-direction: column; 38 | justify-content: center; 39 | overflow: hidden; 40 | width: 100%; 41 | height: auto; 42 | margin: 0; 43 | border: 0; 44 | } 45 | 46 | #embed > p { 47 | width: min(300px, 100%); 48 | margin: 2rem auto; 49 | padding: 4rem 1rem; 50 | border: dashed 2px #ccc; 51 | border-radius: 0.5em; 52 | font-size: 85%; 53 | color: #777; 54 | } 55 | -------------------------------------------------------------------------------- /examples/open-embed-project-id/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | StackBlitz SDK - Open and embed a StackBlitz project 5 | 6 | 7 | 8 | 9 | 10 |
11 |

Open and embed a StackBlitz project

12 | 16 |
17 |
18 |

Embed will go here

19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/open-embed-project-id/index.ts: -------------------------------------------------------------------------------- 1 | import sdk from '@stackblitz/sdk'; 2 | 3 | import './styles.css'; 4 | 5 | // This opens https://stackblitz.com/edit/css-custom-prop-color-values 6 | // in the current window with the Preview pane 7 | function openProject() { 8 | sdk.openProjectId('css-custom-prop-color-values', { 9 | newWindow: false, 10 | view: 'preview', 11 | }); 12 | } 13 | 14 | // This replaces the HTML element with 15 | // the id of "embed" with https://stackblitz.com/edit/css-custom-prop-color-values embedded in an iframe. 16 | function embedProject() { 17 | sdk.embedProjectId('embed', 'css-custom-prop-color-values', { 18 | openFile: 'index.ts', 19 | }); 20 | } 21 | 22 | function setup() { 23 | const embedButton = document.querySelector('[name=embed-project]'); 24 | const openButton = document.querySelector('[name=open-project]'); 25 | embedButton!.addEventListener('click', embedProject); 26 | openButton!.addEventListener('click', openProject); 27 | } 28 | 29 | setup(); 30 | -------------------------------------------------------------------------------- /examples/open-embed-project-id/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sdk-example-open-embed-project-id", 3 | "description": "Demo of the StackBlitz SDK's methods for opening & embedding an existing StackBlitz project", 4 | "version": "0.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "@stackblitz/sdk": "^1.8.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/open-embed-project-id/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | text-align: center; 4 | font-family: system-ui, sans-serif; 5 | color: black; 6 | background-color: white; 7 | } 8 | 9 | body { 10 | height: 100%; 11 | margin: 0; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | h1 { 17 | margin: 1rem; 18 | font-size: 1.25rem; 19 | } 20 | 21 | nav { 22 | margin: 1rem; 23 | font-size: 0.9rem; 24 | } 25 | 26 | button { 27 | margin: 0.2em; 28 | padding: 0.2em 0.5em; 29 | font-size: inherit; 30 | font-family: inherit; 31 | } 32 | 33 | #embed { 34 | display: flex; 35 | flex: 1 1 60%; 36 | flex-direction: column; 37 | justify-content: center; 38 | overflow: hidden; 39 | width: 100%; 40 | height: auto; 41 | margin: 0; 42 | border: 0; 43 | } 44 | 45 | #embed > p { 46 | width: min(300px, 100%); 47 | margin: 2rem auto; 48 | padding: 4rem 1rem; 49 | border: dashed 2px #ccc; 50 | border-radius: 0.5em; 51 | font-size: 85%; 52 | color: #777; 53 | } 54 | -------------------------------------------------------------------------------- /examples/open-embed-webcontainer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | StackBlitz SDK - Open and embed a StackBlitz WebContainer project 5 | 6 | 7 | 8 | 9 | 10 |
11 |

Open and embed a StackBlitz WebContainer project

12 |
13 | 14 |
15 | 19 |
20 |
21 |

Embed will go here

22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/open-embed-webcontainer/index.ts: -------------------------------------------------------------------------------- 1 | import sdk from '@stackblitz/sdk'; 2 | 3 | import './styles.css'; 4 | 5 | // This opens https://stackblitz.com/edit/node 6 | // in the current window with the Preview pane 7 | function openProject() { 8 | sdk.openProjectId('node', { 9 | newWindow: false, 10 | view: 'preview', 11 | }); 12 | } 13 | 14 | // This replaces the HTML element with 15 | // the id of "embed" with https://stackblitz.com/edit/node embedded in an iframe. 16 | function embedProject() { 17 | sdk.embedProjectId('embed', 'node', { 18 | openFile: 'index.ts', 19 | crossOriginIsolated: true, 20 | }); 21 | } 22 | 23 | function toggleCorp(event: Event) { 24 | const queryParams = new URLSearchParams(window.location.search); 25 | const isChecked = (event.target as any)?.checked; 26 | 27 | if (isChecked) { 28 | if (!queryParams.has('corp') || queryParams.get('corp') !== '1') { 29 | queryParams.set('corp', '1'); 30 | } 31 | } else { 32 | queryParams.delete('corp'); 33 | } 34 | 35 | window.location.search = queryParams.toString(); 36 | } 37 | 38 | function setup() { 39 | const embedButton = document.querySelector('[name=embed-project]') as HTMLButtonElement; 40 | const openButton = document.querySelector('[name=open-project]') as HTMLButtonElement; 41 | const corpCheckbox = document.querySelector('[name=corp]') as HTMLInputElement; 42 | 43 | embedButton.addEventListener('click', embedProject); 44 | openButton.addEventListener('click', openProject); 45 | corpCheckbox.addEventListener('change', toggleCorp); 46 | 47 | // mark the checkbox checked if the corp param is already set 48 | const queryParams = new URLSearchParams(window.location.search); 49 | 50 | corpCheckbox.checked = queryParams.get('corp') === '1'; 51 | } 52 | 53 | setup(); 54 | -------------------------------------------------------------------------------- /examples/open-embed-webcontainer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sdk-example-open-embed-webcontainer", 3 | "description": "Demo of the StackBlitz SDK's methods for opening & embedding an existing StackBlitz WebContainer project", 4 | "version": "0.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "@stackblitz/sdk": "^1.8.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/open-embed-webcontainer/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | text-align: center; 4 | font-family: system-ui, sans-serif; 5 | color: black; 6 | background-color: white; 7 | } 8 | 9 | body { 10 | height: 100%; 11 | margin: 0; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | h1 { 17 | margin: 1rem; 18 | font-size: 1.25rem; 19 | } 20 | 21 | nav { 22 | margin: 1rem; 23 | font-size: 0.9rem; 24 | } 25 | 26 | button { 27 | margin: 0.2em; 28 | padding: 0.2em 0.5em; 29 | font-size: inherit; 30 | font-family: inherit; 31 | } 32 | 33 | #embed { 34 | display: flex; 35 | flex: 1 1 60%; 36 | flex-direction: column; 37 | justify-content: center; 38 | overflow: hidden; 39 | width: 100%; 40 | height: auto; 41 | margin: 0; 42 | border: 0; 43 | } 44 | 45 | #embed > p { 46 | width: min(300px, 100%); 47 | margin: 2rem auto; 48 | padding: 4rem 1rem; 49 | border: dashed 2px #ccc; 50 | border-radius: 0.5em; 51 | font-size: 85%; 52 | color: #777; 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stackblitz/sdk", 3 | "version": "1.11.0", 4 | "description": "SDK for generating and embedding StackBlitz projects.", 5 | "license": "MIT", 6 | "author": "Eric Simons", 7 | "homepage": "https://github.com/stackblitz/sdk", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/stackblitz/sdk.git" 11 | }, 12 | "main": "./bundles/sdk.js", 13 | "module": "./bundles/sdk.m.js", 14 | "unpkg": "./bundles/sdk.umd.js", 15 | "jsdelivr": "./bundles/sdk.umd.js", 16 | "types": "./types/index.d.ts", 17 | "files": [ 18 | "bundles", 19 | "types", 20 | "CHANGELOG.md", 21 | "LICENSE.md", 22 | "README.md" 23 | ], 24 | "scripts": { 25 | "build": "npm run build:clean && npm run build:types && npm run build:lib", 26 | "build:clean": "rimraf bundles temp types", 27 | "build:lib": "vite build --mode lib", 28 | "build:types": "tsc -p tsconfig.lib.json", 29 | "format": "prettier --write 'src/**/*.ts' 'test/**/*.ts' vite.*.ts", 30 | "prepack": "npm run test:unit && npm run build", 31 | "start": "vite dev --mode dev --open /examples/", 32 | "start:e2e": "vite dev --mode e2e", 33 | "test": "vitest run --mode test --coverage", 34 | "test:unit": "vitest run --mode test", 35 | "test:e2e": "npx playwright test", 36 | "test:format": "npx prettier --check ." 37 | }, 38 | "devDependencies": { 39 | "@playwright/test": "^1.32.2", 40 | "@rollup/plugin-replace": "^5.0.2", 41 | "@types/body-parser": "^1.19.2", 42 | "@types/lodash": "^4.14.192", 43 | "@vitest/coverage-c8": "^0.29.8", 44 | "@vitest/ui": "^0.29.8", 45 | "body-parser": "^1.20.2", 46 | "happy-dom": "^9.1.0", 47 | "lodash": "^4.17.21", 48 | "prettier": "^2.8.7", 49 | "rimraf": "^4.4.1", 50 | "typescript": "~4.4.4", 51 | "vite": "^4.2.1", 52 | "vite-tsconfig-paths": "^4.0.8", 53 | "vitest": "^0.29.8" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * See https://playwright.dev/docs/test-configuration. 6 | */ 7 | const config: PlaywrightTestConfig = { 8 | testDir: './test/e2e', 9 | outputDir: './temp/e2e-results', 10 | timeout: 30_000, 11 | expect: { 12 | timeout: 5_000, 13 | }, 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: [['html', { outputFolder: './temp/e2e-report' }]], 24 | /* Configure projects for major browsers */ 25 | projects: [ 26 | { 27 | name: 'chromium', 28 | use: { 29 | ...devices['Desktop Chrome'], 30 | }, 31 | }, 32 | ], 33 | use: { 34 | actionTimeout: 0, 35 | trace: 'on-first-retry', 36 | }, 37 | webServer: { 38 | command: `npm run start:e2e`, 39 | port: 4001, 40 | }, 41 | }; 42 | 43 | export default config; 44 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import { CONNECT_INTERVAL, CONNECT_MAX_ATTEMPTS } from './constants'; 2 | import { genID } from './helpers'; 3 | import { VM } from './vm'; 4 | 5 | const connections: Connection[] = []; 6 | 7 | export class Connection { 8 | element: HTMLIFrameElement; 9 | id: string; 10 | pending: Promise; 11 | vm?: VM; 12 | 13 | constructor(element: HTMLIFrameElement) { 14 | this.id = genID(); 15 | this.element = element; 16 | this.pending = new Promise((resolve, reject) => { 17 | const listenForSuccess = ({ data, ports }: MessageEvent) => { 18 | if (data?.action === 'SDK_INIT_SUCCESS' && data.id === this.id) { 19 | this.vm = new VM(ports[0], data.payload); 20 | resolve(this.vm); 21 | cleanup(); 22 | } 23 | }; 24 | 25 | const pingFrame = () => { 26 | this.element.contentWindow?.postMessage( 27 | { 28 | action: 'SDK_INIT', 29 | id: this.id, 30 | }, 31 | '*' 32 | ); 33 | }; 34 | 35 | // Remove the listener and interval. 36 | function cleanup() { 37 | window.clearInterval(interval); 38 | window.removeEventListener('message', listenForSuccess); 39 | } 40 | 41 | // First we want to set up the listener for the frame 42 | window.addEventListener('message', listenForSuccess); 43 | // Then, lets immediately ping the frame. 44 | pingFrame(); 45 | // Keep track of the current try number 46 | let attempts = 0; 47 | const interval = window.setInterval(() => { 48 | // If the VM connection is open, cleanup and return 49 | // This shouldn't ever happen, but just in case there's some race condition... 50 | if (this.vm) { 51 | cleanup(); 52 | return; 53 | } 54 | 55 | // If we've exceeded the max retries, fail this promise. 56 | if (attempts >= CONNECT_MAX_ATTEMPTS) { 57 | cleanup(); 58 | reject('Timeout: Unable to establish a connection with the StackBlitz VM'); 59 | // Remove the (now) failed connection from the connections array 60 | connections.forEach((connection, index) => { 61 | if (connection.id === this.id) { 62 | connections.splice(index, 1); 63 | } 64 | }); 65 | return; 66 | } 67 | 68 | attempts++; 69 | pingFrame(); 70 | }, CONNECT_INTERVAL); 71 | }); 72 | 73 | connections.push(this); 74 | } 75 | } 76 | 77 | // Accepts either the frame element OR the id. 78 | export const getConnection = (identifier: string | HTMLIFrameElement) => { 79 | const key = identifier instanceof Element ? 'element' : 'id'; 80 | return connections.find((c) => c[key] === identifier) ?? null; 81 | }; 82 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Number of milliseconds between attempts to get a response from an embedded frame 3 | */ 4 | export const CONNECT_INTERVAL = 500; 5 | 6 | /** 7 | * How many times should we try to get an init response from an embedded frame 8 | */ 9 | export const CONNECT_MAX_ATTEMPTS = 20; 10 | 11 | /** 12 | * Default height attribute for iframes 13 | */ 14 | export const DEFAULT_FRAME_HEIGHT = 300; 15 | 16 | // Local declaration to satisfy TypeScript. 17 | // Usage of this variable will be replaced at build time, 18 | // and should not appear in the built bundles and .d.ts files 19 | declare var __STACKBLITZ_SERVER_ORIGIN__: string | undefined; 20 | 21 | /** 22 | * Origin of the StackBlitz instance 23 | */ 24 | export const DEFAULT_ORIGIN: string = __STACKBLITZ_SERVER_ORIGIN__ || 'https://stackblitz.com'; 25 | 26 | /** 27 | * List of supported template names. 28 | */ 29 | export const PROJECT_TEMPLATES = [ 30 | 'angular-cli', 31 | 'create-react-app', 32 | 'html', 33 | 'javascript', 34 | 'node', 35 | 'polymer', 36 | 'typescript', 37 | 'vue', 38 | ] as const; 39 | 40 | /** 41 | * Supported sidebar views 42 | */ 43 | export const UI_SIDEBAR_VIEWS = ['project', 'search', 'ports', 'settings'] as const; 44 | 45 | /** 46 | * Supported editor themes 47 | */ 48 | export const UI_THEMES = ['light', 'dark'] as const; 49 | 50 | /** 51 | * Supported editor view modes 52 | */ 53 | export const UI_VIEWS = ['editor', 'preview'] as const; 54 | -------------------------------------------------------------------------------- /src/generate.ts: -------------------------------------------------------------------------------- 1 | import type { Project, EmbedOptions, OpenOptions } from './interfaces'; 2 | import { PROJECT_TEMPLATES } from './constants'; 3 | import { embedUrl, openTarget, openUrl } from './helpers'; 4 | 5 | function createHiddenInput(name: string, value: string) { 6 | const input = document.createElement('input'); 7 | input.type = 'hidden'; 8 | input.name = name; 9 | input.value = value; 10 | return input; 11 | } 12 | 13 | /** 14 | * Encode file paths for use in input name attributes. 15 | * We need to replace square brackets (as used by Next.js, SvelteKit, etc.), 16 | * with custom escape sequences. Important: do not encodeURIComponent the 17 | * whole path, for compatibility with the StackBlitz backend. 18 | */ 19 | function encodeFilePath(path: string) { 20 | return path.replace(/\[/g, '%5B').replace(/\]/g, '%5D'); 21 | } 22 | 23 | export function createProjectForm({ 24 | template, 25 | title, 26 | description, 27 | dependencies, 28 | files, 29 | settings, 30 | }: Project) { 31 | if (!PROJECT_TEMPLATES.includes(template)) { 32 | const names = PROJECT_TEMPLATES.map((t) => `'${t}'`).join(', '); 33 | console.warn(`Unsupported project.template: must be one of ${names}`); 34 | } 35 | 36 | const inputs: HTMLInputElement[] = []; 37 | const addInput = (name: string, value: string, defaultValue = '') => { 38 | inputs.push(createHiddenInput(name, typeof value === 'string' ? value : defaultValue)); 39 | }; 40 | 41 | addInput('project[title]', title); 42 | if (typeof description === 'string' && description.length > 0) { 43 | addInput('project[description]', description); 44 | } 45 | addInput('project[template]', template, 'javascript'); 46 | 47 | if (dependencies) { 48 | if (template === 'node') { 49 | console.warn( 50 | `Invalid project.dependencies: dependencies must be provided as a 'package.json' file when using the 'node' template.` 51 | ); 52 | } else { 53 | addInput('project[dependencies]', JSON.stringify(dependencies)); 54 | } 55 | } 56 | 57 | if (settings) { 58 | addInput('project[settings]', JSON.stringify(settings)); 59 | } 60 | 61 | Object.entries(files).forEach(([path, contents]) => { 62 | addInput(`project[files][${encodeFilePath(path)}]`, contents); 63 | }); 64 | 65 | const form = document.createElement('form'); 66 | form.method = 'POST'; 67 | form.setAttribute('style', 'display:none!important;'); 68 | form.append(...inputs); 69 | return form; 70 | } 71 | 72 | export function createProjectFrameHTML(project: Project, options?: EmbedOptions) { 73 | const form = createProjectForm(project); 74 | form.action = embedUrl('/run', options); 75 | form.id = 'sb_run'; 76 | 77 | const html = ` 78 | 79 | 80 | 81 | ${form.outerHTML} 82 | 83 | 84 | `; 85 | 86 | return html; 87 | } 88 | 89 | export function openNewProject(project: Project, options?: OpenOptions) { 90 | const form = createProjectForm(project); 91 | form.action = openUrl('/run', options); 92 | form.target = openTarget(options); 93 | 94 | document.body.appendChild(form); 95 | form.submit(); 96 | document.body.removeChild(form); 97 | } 98 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { EmbedOptions, OpenOptions } from './interfaces'; 2 | import { DEFAULT_FRAME_HEIGHT, DEFAULT_ORIGIN } from './constants'; 3 | import { buildParams } from './params'; 4 | 5 | type Route = '/run' | `/edit/${string}` | `/github/${string}` | `/fork/${string}`; 6 | 7 | /** 8 | * Pseudo-random id string for internal accounting. 9 | * 8 characters long, and collisions around 1 per million. 10 | */ 11 | export function genID() { 12 | return Math.random().toString(36).slice(2, 6) + Math.random().toString(36).slice(2, 6); 13 | } 14 | 15 | export function openUrl(route: Route, options?: OpenOptions) { 16 | return `${getOrigin(options)}${route}${buildParams(options)}`; 17 | } 18 | 19 | export function embedUrl(route: Route, options?: EmbedOptions) { 20 | const config: EmbedOptions = { 21 | forceEmbedLayout: true, 22 | }; 23 | if (options && typeof options === 'object') { 24 | Object.assign(config, options); 25 | } 26 | return `${getOrigin(config)}${route}${buildParams(config)}`; 27 | } 28 | 29 | function getOrigin(options: OpenOptions & EmbedOptions = {}) { 30 | const origin = typeof options.origin === 'string' ? options.origin : DEFAULT_ORIGIN; 31 | return origin.replace(/\/$/, ''); 32 | } 33 | 34 | export function replaceAndEmbed( 35 | target: HTMLElement, 36 | frame: HTMLIFrameElement, 37 | options?: EmbedOptions 38 | ) { 39 | if (!frame || !target || !target.parentNode) { 40 | throw new Error('Invalid Element'); 41 | } 42 | if (target.id) { 43 | frame.id = target.id; 44 | } 45 | if (target.className) { 46 | frame.className = target.className; 47 | } 48 | setFrameDimensions(frame, options); 49 | setFrameAllowList(target, frame, options); 50 | target.replaceWith(frame); 51 | } 52 | 53 | export function findElement(elementOrId: string | HTMLElement) { 54 | if (typeof elementOrId === 'string') { 55 | const element = document.getElementById(elementOrId); 56 | if (!element) { 57 | throw new Error(`Could not find element with id '${elementOrId}'`); 58 | } 59 | return element; 60 | } else if (elementOrId instanceof HTMLElement) { 61 | return elementOrId; 62 | } 63 | throw new Error(`Invalid element: ${elementOrId}`); 64 | } 65 | 66 | export function openTarget(options?: OpenOptions) { 67 | return options && options.newWindow === false ? '_self' : '_blank'; 68 | } 69 | 70 | function setFrameDimensions(frame: HTMLIFrameElement, options: EmbedOptions = {}) { 71 | const height: string = Object.hasOwnProperty.call(options, 'height') 72 | ? `${options.height}` 73 | : `${DEFAULT_FRAME_HEIGHT}`; 74 | const width: string | undefined = Object.hasOwnProperty.call(options, 'width') 75 | ? `${options.width}` 76 | : undefined; 77 | 78 | frame.setAttribute('height', height); 79 | if (width) { 80 | frame.setAttribute('width', width); 81 | } else { 82 | frame.setAttribute('style', 'width:100%;'); 83 | } 84 | } 85 | 86 | function setFrameAllowList( 87 | target: HTMLElement & { allow?: string }, 88 | frame: HTMLIFrameElement, 89 | options: EmbedOptions = {} 90 | ) { 91 | const allowList = target.allow?.split(';')?.map((key) => key.trim()) ?? []; 92 | 93 | if (options.crossOriginIsolated && !allowList.includes('cross-origin-isolated')) { 94 | allowList.push('cross-origin-isolated'); 95 | } 96 | 97 | if (allowList.length > 0) { 98 | frame.allow = allowList.join('; '); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | connect, 3 | embedGithubProject, 4 | embedProject, 5 | embedProjectId, 6 | openGithubProject, 7 | openProject, 8 | openProjectId, 9 | } from './lib'; 10 | 11 | // Explicitly export public types (vs using `export * from './interfaces'`), 12 | // so that additions to interfaces don't become a part of our public API by mistake. 13 | export type { 14 | Project, 15 | ProjectDependencies, 16 | ProjectFiles, 17 | ProjectSettings, 18 | ProjectTemplate, 19 | ProjectOptions, 20 | EmbedOptions, 21 | OpenOptions, 22 | OpenFileOption, 23 | UiThemeOption, 24 | UiViewOption, 25 | } from './interfaces'; 26 | export type { FsDiff, VM } from './vm'; 27 | 28 | const StackBlitzSDK = { 29 | connect, 30 | embedGithubProject, 31 | embedProject, 32 | embedProjectId, 33 | openGithubProject, 34 | openProject, 35 | openProjectId, 36 | }; 37 | 38 | // Export a single object with methods, for compatibility with UMD and CommonJS. 39 | // Ideally we would also have named exports, but that can create incompatibilities 40 | // with some bundlers. To revisit in v2? 41 | export default StackBlitzSDK; 42 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import type { PROJECT_TEMPLATES, UI_SIDEBAR_VIEWS, UI_THEMES, UI_VIEWS } from './constants'; 2 | 3 | export interface Project { 4 | title: string; 5 | description?: string; 6 | /** 7 | * The project’s template name tells StackBlitz how to compile and run project files. 8 | * 9 | * Template values supported on https://stackblitz.com include: 10 | * - EngineBlock environment: `angular-cli`, `create-react-app`, `javascript`, `polymer`, `typescript`, `vue` 11 | * - WebContainers environment: `node` 12 | * 13 | * @see https://developer.stackblitz.com/guides/user-guide/available-environments 14 | */ 15 | template: ProjectTemplate; 16 | /** 17 | * Provide project files, as code strings. 18 | * 19 | * Binary files and blobs are not supported. 20 | */ 21 | files: ProjectFiles; 22 | /** 23 | * Define npm dependencies for EngineBlock projects. 24 | * 25 | * For WebContainers-based projects (when using `template: 'node'`), this is ignored, 26 | * and dependencies must be defined in the `package.json` file in the `files` object. 27 | */ 28 | dependencies?: ProjectDependencies; 29 | settings?: ProjectSettings; 30 | /** 31 | * @deprecated Tags are ignored by the StackBlitz SDK since v1.5.4 32 | */ 33 | tags?: string[]; 34 | } 35 | 36 | export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[number]; 37 | 38 | export interface ProjectDependencies { 39 | [name: string]: string; 40 | } 41 | 42 | export interface ProjectFiles { 43 | [path: string]: string; 44 | } 45 | 46 | export interface ProjectSettings { 47 | compile?: { 48 | trigger?: 'auto' | 'keystroke' | 'save' | string; 49 | action?: 'hmr' | 'refresh' | string; 50 | clearConsole?: boolean; 51 | }; 52 | } 53 | 54 | export interface ProjectOptions { 55 | /** 56 | * Show a UI dialog asking users to click a button to run the project. 57 | * 58 | * Defaults to `false`. 59 | */ 60 | clickToLoad?: boolean; 61 | /** 62 | * Height of the Console panel below the preview page (as a percentage number, between `0` and `100`). 63 | * 64 | * By default, the Console will appear collapsed, and can be opened by users. 65 | * 66 | * This option is ignored in WebContainers-based projects. 67 | */ 68 | devToolsHeight?: number; 69 | /** 70 | * Use the “embed” layout of the editor. 71 | * 72 | * Defaults to `true` for `embedProject*` methods, and `false` for `openProject*` methods. 73 | * 74 | * @deprecated May be removed in a future release. 75 | */ 76 | forceEmbedLayout?: boolean; 77 | /** 78 | * Completely hide the Console panel below the preview page. 79 | * 80 | * This option is ignored in WebContainers-based projects. 81 | * 82 | * Defaults to `false`. 83 | */ 84 | hideDevTools?: boolean; 85 | /** 86 | * Hide the ActivityBar (sidebar icons). 87 | * 88 | * Defaults to `false`. 89 | */ 90 | hideExplorer?: boolean; 91 | /** 92 | * Select one or several project files to open initially. 93 | * 94 | * Example usage: 95 | * 96 | * // open a single file 97 | * openFile: 'src/index.js' 98 | * 99 | * // open three files in three editor tabs 100 | * openFile: 'package.json,src/index.js,src/components/App.js' 101 | * 102 | * // open three files in two side-by-side editor panes 103 | * openFile: ['package.json,src/index.js', 'src/components/App.js'] 104 | */ 105 | openFile?: OpenFileOption; 106 | /** 107 | * Set the origin URL of your StackBlitz EE server. 108 | * 109 | * Defaults to `https://stackblitz.com`. 110 | */ 111 | origin?: string; 112 | /** 113 | * Set the organization where you want to run the project. 114 | * 115 | * Defaults to no organization. 116 | */ 117 | organization?: { 118 | provider: 'github'; 119 | name: string; 120 | }; 121 | /** 122 | * Show the sidebar as open or closed on page load. 123 | * 124 | * This might be ignored on narrow viewport widths (mobile and/or tablets). 125 | * 126 | * On larger viewports, defaults to `false` for `embedProject*` methods, and `true` for `openProject*` methods. 127 | */ 128 | showSidebar?: boolean; 129 | /** 130 | * Choose the sidebar view to open on project load. 131 | * 132 | * Available views: `project` (default), `search`, `ports` (WebContainers only) and `settings`. 133 | * 134 | * @since 1.9.0 135 | */ 136 | sidebarView?: UiSidebarView; 137 | /** 138 | * Name(s) of the npm script(s) to run on project load. 139 | * 140 | * Must be a single script name, or a comma-separated list of script names, matching the keys of the `scripts` object in the `package.json` file at the root of the project. Arbitrary shell commands are not supported. 141 | * 142 | * Example usage: 143 | * 144 | * // Run the 'build' script after dependencies are installed 145 | * startScript: 'build' 146 | * 147 | * // Run the 'build' script then the 'serve' script, which may look like: 148 | * // `npm install && npm run build && npm run serve` 149 | * startScript: 'build,serve' 150 | * 151 | * Defaults to looking for a `dev` script or a `start` script. Ignored in EngineBlock projects. 152 | * 153 | * @since 1.9.0 154 | */ 155 | startScript?: string; 156 | /** 157 | * Height of the Terminal panel below the editor (as a percentage number). 158 | * 159 | * Values such as `0` and `100` may not be applied as-is, but result instead in the minimum or maximum height allowed for the Terminal. 160 | * 161 | * The Terminal only appears in WebContainers-based projects. 162 | */ 163 | terminalHeight?: number; 164 | /** 165 | * Select the color theme for the editor UI. 166 | * 167 | * Available themes: `dark` (default) and `light`. 168 | */ 169 | theme?: UiThemeOption; 170 | /** 171 | * Show only the code editor or only the preview page. 172 | * 173 | * Defaults to showing both the editor and the preview. 174 | */ 175 | view?: UiViewOption; 176 | } 177 | 178 | export interface OpenOptions extends ProjectOptions { 179 | /** 180 | * Opens the project in a new browser tab. 181 | * 182 | * Defaults to `true`; use `false` to open in the current tab. 183 | */ 184 | newWindow?: boolean; 185 | /** 186 | * Opens the project with the editor UI partially hidden (known as “zen mode”). 187 | * 188 | * Defaults to `false`. 189 | * 190 | * @since 1.9.0 191 | */ 192 | zenMode?: boolean; 193 | } 194 | 195 | export interface EmbedOptions extends ProjectOptions { 196 | /** 197 | * Height of the embed iframe 198 | */ 199 | height?: number | string; 200 | /** 201 | * Width of the embed iframe (defaults to `100%`) 202 | */ 203 | width?: number | string; 204 | /** 205 | * Hide the preview URL in embeds. 206 | */ 207 | hideNavigation?: boolean; 208 | /** 209 | * Load the project with the proper cross-origin isolation headers. 210 | * 211 | * @see https://blog.stackblitz.com/posts/cross-browser-with-coop-coep/ 212 | */ 213 | crossOriginIsolated?: boolean; 214 | } 215 | 216 | export type OpenFileOption = string | string[]; 217 | 218 | export type UiSidebarView = 'default' | (typeof UI_SIDEBAR_VIEWS)[number]; 219 | 220 | export type UiThemeOption = 'default' | (typeof UI_THEMES)[number]; 221 | 222 | export type UiViewOption = 'default' | (typeof UI_VIEWS)[number]; 223 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import type { Project, OpenOptions, EmbedOptions } from './interfaces'; 2 | import type { VM } from './vm'; 3 | import { Connection, getConnection } from './connection'; 4 | import { openNewProject, createProjectFrameHTML } from './generate'; 5 | import { embedUrl, findElement, openTarget, openUrl, replaceAndEmbed } from './helpers'; 6 | 7 | /** 8 | * Get a VM instance for an existing StackBlitz project iframe. 9 | */ 10 | export function connect(frameEl: HTMLIFrameElement): Promise { 11 | if (!frameEl?.contentWindow) { 12 | return Promise.reject('Provided element is not an iframe.'); 13 | } 14 | const connection = getConnection(frameEl) ?? new Connection(frameEl); 15 | return connection.pending; 16 | } 17 | 18 | /** 19 | * Open an existing StackBlitz project in a new tab (or in the current window). 20 | */ 21 | export function openProject(project: Project, options?: OpenOptions) { 22 | openNewProject(project, options); 23 | } 24 | 25 | /** 26 | * Open an existing StackBlitz project in a new tab (or in the current window). 27 | */ 28 | export function openProjectId(projectId: string, options?: OpenOptions) { 29 | const url = openUrl(`/edit/${projectId}`, options); 30 | const target = openTarget(options); 31 | window.open(url, target); 32 | } 33 | 34 | /** 35 | * Open a project from Github and open it in a new tab (or in the current window). 36 | * 37 | * Example usage: 38 | * 39 | * sdk.openGithubProject('some/repository'); 40 | * sdk.openGithubProject('some/repository/tree/some-branch'); 41 | */ 42 | export function openGithubProject(repoSlug: string, options?: OpenOptions) { 43 | const url = openUrl(`/github/${repoSlug}`, options); 44 | const target = openTarget(options); 45 | window.open(url, target); 46 | } 47 | 48 | /** 49 | * Create a new project and embed it on the current page. 50 | * 51 | * Returns a promise resolving to a VM instance. 52 | */ 53 | export function embedProject( 54 | elementOrId: string | HTMLElement, 55 | project: Project, 56 | options?: EmbedOptions 57 | ): Promise { 58 | const element = findElement(elementOrId); 59 | const html = createProjectFrameHTML(project, options); 60 | const frame = document.createElement('iframe'); 61 | 62 | replaceAndEmbed(element, frame, options); 63 | 64 | // HTML needs to be written after iframe is embedded 65 | frame.contentDocument?.write(html); 66 | 67 | return connect(frame); 68 | } 69 | 70 | /** 71 | * Embeds an existing StackBlitz project on the current page. 72 | * 73 | * Returns a promise resolving to a VM instance. 74 | */ 75 | export function embedProjectId( 76 | elementOrId: string | HTMLElement, 77 | projectId: string, 78 | options?: EmbedOptions 79 | ): Promise { 80 | const element = findElement(elementOrId); 81 | const frame = document.createElement('iframe'); 82 | frame.src = embedUrl(`/edit/${projectId}`, options); 83 | 84 | replaceAndEmbed(element, frame, options); 85 | 86 | return connect(frame); 87 | } 88 | 89 | /** 90 | * Embeds a project from Github on the current page. 91 | * 92 | * Returns a promise resolving to a VM instance. 93 | */ 94 | export function embedGithubProject( 95 | elementOrId: string | HTMLElement, 96 | repoSlug: string, 97 | options?: EmbedOptions 98 | ): Promise { 99 | const element = findElement(elementOrId); 100 | const frame = document.createElement('iframe'); 101 | frame.src = embedUrl(`/github/${repoSlug}`, options); 102 | 103 | replaceAndEmbed(element, frame, options); 104 | 105 | return connect(frame); 106 | } 107 | -------------------------------------------------------------------------------- /src/params.ts: -------------------------------------------------------------------------------- 1 | import type { EmbedOptions, OpenOptions } from './interfaces'; 2 | 3 | import { UI_SIDEBAR_VIEWS, UI_THEMES, UI_VIEWS } from './constants'; 4 | 5 | export type ParamOptions = Omit< 6 | OpenOptions & EmbedOptions, 7 | 'origin' | 'newWindow' | 'height' | 'width' 8 | >; 9 | 10 | /** 11 | * URL parameter names supported by the StackBlitz instance. 12 | * 13 | * A couple notes: 14 | * 15 | * - Names don't always match the keys in EmbedOptions / OpenOptions. 16 | * For example, options use `openFile` but the expected param is `file`. 17 | * - While updated instances perform a case-insensitive lookup for query 18 | * parameters, some Enterprise Edition deployments may not, and we need to 19 | * use specific (and sometimes inconsistent) casing; see for example 20 | * 'hidedevtools' vs 'hideNavigation'. 21 | */ 22 | type ParamName = 23 | | '_test' 24 | | 'clicktoload' 25 | | 'ctl' 26 | | 'devtoolsheight' 27 | | 'embed' 28 | | 'file' 29 | | 'hidedevtools' 30 | | 'hideExplorer' 31 | | 'hideNavigation' 32 | | 'initialpath' 33 | | 'showSidebar' 34 | | 'sidebarView' 35 | | 'startScript' 36 | | 'terminalHeight' 37 | | 'theme' 38 | | 'view' 39 | | 'zenMode' 40 | | 'orgName' 41 | | 'orgProvider' 42 | | 'corp'; 43 | 44 | export const generators: Record string> = { 45 | clickToLoad: (value: ParamOptions['clickToLoad']) => trueParam('ctl', value), 46 | devToolsHeight: (value: ParamOptions['devToolsHeight']) => percentParam('devtoolsheight', value), 47 | forceEmbedLayout: (value: ParamOptions['forceEmbedLayout']) => trueParam('embed', value), 48 | hideDevTools: (value: ParamOptions['hideDevTools']) => trueParam('hidedevtools', value), 49 | hideExplorer: (value: ParamOptions['hideExplorer']) => trueParam('hideExplorer', value), 50 | hideNavigation: (value: ParamOptions['hideNavigation']) => trueParam('hideNavigation', value), 51 | openFile: (value: ParamOptions['openFile']) => stringParams('file', value), 52 | showSidebar: (value: ParamOptions['showSidebar']) => booleanParam('showSidebar', value), 53 | sidebarView: (value: ParamOptions['sidebarView']) => 54 | enumParam('sidebarView', value, UI_SIDEBAR_VIEWS), 55 | startScript: (value: ParamOptions['startScript']) => stringParams('startScript', value), 56 | terminalHeight: (value: ParamOptions['terminalHeight']) => percentParam('terminalHeight', value), 57 | theme: (value: ParamOptions['theme']) => enumParam('theme', value, UI_THEMES), 58 | view: (value: ParamOptions['view']) => enumParam('view', value, UI_VIEWS), 59 | zenMode: (value: ParamOptions['zenMode']) => trueParam('zenMode', value), 60 | organization: (value: ParamOptions['organization']) => 61 | `${stringParams('orgName', value?.name)}&${stringParams('orgProvider', value?.provider)}`, 62 | crossOriginIsolated: (value: ParamOptions['crossOriginIsolated']) => trueParam('corp', value), 63 | }; 64 | 65 | export function buildParams(options: ParamOptions = {}): string { 66 | const params: string[] = Object.entries(options) 67 | .map(([key, value]) => { 68 | if (value != null && generators.hasOwnProperty(key)) { 69 | return generators[key as keyof ParamOptions](value); 70 | } 71 | return ''; 72 | }) 73 | .filter(Boolean); 74 | 75 | return params.length ? `?${params.join('&')}` : ''; 76 | } 77 | 78 | export function trueParam(name: ParamName, value?: boolean): string { 79 | if (value === true) { 80 | return `${name}=1`; 81 | } 82 | return ''; 83 | } 84 | 85 | export function booleanParam(name: ParamName, value?: boolean): string { 86 | if (typeof value === 'boolean') { 87 | return `${name}=${value ? '1' : '0'}`; 88 | } 89 | return ''; 90 | } 91 | 92 | export function percentParam(name: ParamName, value?: number): string { 93 | if (typeof value === 'number' && !Number.isNaN(value)) { 94 | const clamped = Math.min(100, Math.max(0, value)); 95 | return `${name}=${encodeURIComponent(Math.round(clamped))}`; 96 | } 97 | return ''; 98 | } 99 | 100 | export function enumParam( 101 | name: ParamName, 102 | value: string = '', 103 | allowList: readonly string[] = [] 104 | ): string { 105 | if (allowList.includes(value)) { 106 | return `${name}=${encodeURIComponent(value)}`; 107 | } 108 | return ''; 109 | } 110 | 111 | export function stringParams(name: ParamName, value?: string | string[]): string { 112 | const values = Array.isArray(value) ? value : [value]; 113 | return values 114 | .filter((val) => typeof val === 'string' && val.trim() !== '') 115 | .map((val) => `${name}=${encodeURIComponent(val!)}`) 116 | .join('&'); 117 | } 118 | -------------------------------------------------------------------------------- /src/rdc.ts: -------------------------------------------------------------------------------- 1 | import { genID } from './helpers'; 2 | 3 | interface MessagePayload { 4 | [key: string]: any; 5 | } 6 | 7 | interface MessagePayloadWithMetadata extends MessagePayload { 8 | __reqid: string; 9 | __success: boolean; 10 | __error?: string; 11 | } 12 | 13 | interface MessageData { 14 | type: string; 15 | payload: MessagePayloadWithMetadata; 16 | } 17 | 18 | interface RequestData { 19 | type: string; 20 | payload: MessagePayload; 21 | } 22 | 23 | interface PendingResolvers { 24 | [id: string]: { 25 | resolve(value: any): void; 26 | reject(error: string): void; 27 | }; 28 | } 29 | 30 | export class RDC { 31 | private port: MessagePort; 32 | private pending: PendingResolvers = {}; 33 | 34 | constructor(port: MessagePort) { 35 | this.port = port; 36 | this.port.onmessage = this.messageListener.bind(this); 37 | } 38 | 39 | public request({ type, payload }: RequestData): Promise { 40 | return new Promise((resolve, reject) => { 41 | const id = genID(); 42 | this.pending[id] = { resolve, reject }; 43 | this.port.postMessage({ 44 | type, 45 | payload: { 46 | ...payload, 47 | // Ensure the payload object includes the request ID 48 | __reqid: id, 49 | }, 50 | }); 51 | }); 52 | } 53 | 54 | private messageListener(event: MessageEvent) { 55 | if (typeof event.data.payload?.__reqid !== 'string') { 56 | return; 57 | } 58 | 59 | const { type, payload } = event.data; 60 | const { __reqid: id, __success: success, __error: error } = payload; 61 | 62 | if (this.pending[id]) { 63 | if (success) { 64 | this.pending[id].resolve(this.cleanResult(payload)); 65 | } else { 66 | this.pending[id].reject(error ? `${type}: ${error}` : type); 67 | } 68 | delete this.pending[id]; 69 | } 70 | } 71 | 72 | private cleanResult(payload: MessagePayloadWithMetadata): MessagePayload | null { 73 | const result: Partial = { ...payload }; 74 | delete result.__reqid; 75 | delete result.__success; 76 | delete result.__error; 77 | // Null the result if payload was empty besides the private metadata fields 78 | return Object.keys(result).length ? result : null; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/vm.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | OpenFileOption, 3 | ProjectDependencies, 4 | ProjectFiles, 5 | UiThemeOption, 6 | UiViewOption, 7 | } from './interfaces'; 8 | import { RDC } from './rdc'; 9 | 10 | export interface FsDiff { 11 | create: { 12 | [path: string]: string; 13 | }; 14 | destroy: string[]; 15 | } 16 | 17 | export class VM { 18 | private _rdc: RDC; 19 | 20 | constructor(port: MessagePort, config: { previewOrigin?: string }) { 21 | this._rdc = new RDC(port); 22 | 23 | Object.defineProperty(this.preview, 'origin', { 24 | value: typeof config.previewOrigin === 'string' ? config.previewOrigin : null, 25 | writable: false, 26 | }); 27 | } 28 | 29 | /** 30 | * Apply batch updates to the project files in one call. 31 | */ 32 | applyFsDiff(diff: FsDiff): Promise { 33 | const isObject = (val: any) => val !== null && typeof val === 'object'; 34 | if (!isObject(diff) || !isObject(diff.create)) { 35 | throw new Error('Invalid diff object: expected diff.create to be an object.'); 36 | } else if (!Array.isArray(diff.destroy)) { 37 | throw new Error('Invalid diff object: expected diff.destroy to be an array.'); 38 | } 39 | 40 | return this._rdc.request({ 41 | type: 'SDK_APPLY_FS_DIFF', 42 | payload: diff, 43 | }); 44 | } 45 | 46 | /** 47 | * Get the project’s defined dependencies. 48 | * 49 | * In EngineBlock projects, version numbers represent the resolved dependency versions. 50 | * In WebContainers-based projects, returns data from the project’s `package.json` without resolving installed version numbers. 51 | */ 52 | getDependencies(): Promise { 53 | return this._rdc.request({ 54 | type: 'SDK_GET_DEPS_SNAPSHOT', 55 | payload: {}, 56 | }); 57 | } 58 | 59 | /** 60 | * Get a snapshot of the project files and their content. 61 | */ 62 | getFsSnapshot(): Promise { 63 | return this._rdc.request({ 64 | type: 'SDK_GET_FS_SNAPSHOT', 65 | payload: {}, 66 | }); 67 | } 68 | 69 | public editor = { 70 | /** 71 | * Open one of several files in tabs and/or split panes. 72 | * 73 | * @since 1.7.0 Added support for opening multiple files 74 | */ 75 | openFile: (path: OpenFileOption): Promise => { 76 | return this._rdc.request({ 77 | type: 'SDK_OPEN_FILE', 78 | payload: { path }, 79 | }); 80 | }, 81 | 82 | /** 83 | * Set a project file as the currently selected file. 84 | * 85 | * - This may update the highlighted file in the file explorer, 86 | * and the currently open and/or focused editor tab. 87 | * - It will _not_ open a new editor tab if the provided path does not 88 | * match a currently open tab. See `vm.editor.openFile` to open files. 89 | * 90 | * @since 1.7.0 91 | * @experimental 92 | */ 93 | setCurrentFile: (path: string): Promise => { 94 | return this._rdc.request({ 95 | type: 'SDK_SET_CURRENT_FILE', 96 | payload: { path }, 97 | }); 98 | }, 99 | 100 | /** 101 | * Change the color theme 102 | * 103 | * @since 1.7.0 104 | */ 105 | setTheme: (theme: UiThemeOption): Promise => { 106 | return this._rdc.request({ 107 | type: 'SDK_SET_UI_THEME', 108 | payload: { theme }, 109 | }); 110 | }, 111 | 112 | /** 113 | * Change the display mode of the project: 114 | * 115 | * - `default`: show the editor and preview pane 116 | * - `editor`: show the editor pane only 117 | * - `preview`: show the preview pane only 118 | * 119 | * @since 1.7.0 120 | */ 121 | setView: (view: UiViewOption): Promise => { 122 | return this._rdc.request({ 123 | type: 'SDK_SET_UI_VIEW', 124 | payload: { view }, 125 | }); 126 | }, 127 | 128 | /** 129 | * Change the display mode of the sidebar: 130 | * 131 | * - `true`: show the sidebar 132 | * - `false`: hide the sidebar 133 | * 134 | * @since 1.7.0 135 | */ 136 | showSidebar: (visible: boolean = true): Promise => { 137 | return this._rdc.request({ 138 | type: 'SDK_TOGGLE_SIDEBAR', 139 | payload: { visible }, 140 | }); 141 | }, 142 | }; 143 | 144 | public preview = { 145 | /** 146 | * The origin (protocol and domain) of the preview iframe. 147 | * 148 | * In WebContainers-based projects, the origin will always be `null`; 149 | * try using `vm.preview.getUrl` instead. 150 | * 151 | * @see https://developer.stackblitz.com/guides/user-guide/available-environments 152 | */ 153 | origin: '' as string | null, 154 | 155 | /** 156 | * Get the current preview URL. 157 | * 158 | * In both and EngineBlock and WebContainers-based projects, the preview URL 159 | * may not reflect the exact path of the current page, after user navigation. 160 | * 161 | * In WebContainers-based projects, the preview URL will be `null` initially, 162 | * and until the project starts a web server. 163 | * 164 | * @since 1.7.0 165 | * @experimental 166 | */ 167 | getUrl: (): Promise => { 168 | return this._rdc 169 | .request<{ url: string }>({ 170 | type: 'SDK_GET_PREVIEW_URL', 171 | payload: {}, 172 | }) 173 | .then((data) => data?.url ?? null); 174 | }, 175 | 176 | /** 177 | * Change the path of the preview URL. 178 | * 179 | * In WebContainers-based projects, this will be ignored if there is no 180 | * currently running web server. 181 | * 182 | * @since 1.7.0 183 | * @experimental 184 | */ 185 | setUrl: (path: string = '/'): Promise => { 186 | if (typeof path !== 'string' || !path.startsWith('/')) { 187 | throw new Error(`Invalid argument: expected a path starting with '/', got '${path}'`); 188 | } 189 | return this._rdc.request({ 190 | type: 'SDK_SET_PREVIEW_URL', 191 | payload: { path }, 192 | }); 193 | }, 194 | }; 195 | } 196 | -------------------------------------------------------------------------------- /test/e2e/embedProject.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('embedProjectId', async ({ page }) => { 4 | await page.goto('/test/pages/blank.html'); 5 | 6 | await page.evaluate(() => { 7 | window.StackBlitzSDK.embedProjectId('embed', 'js'); 8 | }); 9 | 10 | const iframe = page.locator('iframe'); 11 | expect(iframe).toBeVisible(); 12 | expect(iframe).toHaveAttribute('id', 'embed'); 13 | expect(await iframe.getAttribute('src')).toContain('/edit/js?embed=1'); 14 | }); 15 | -------------------------------------------------------------------------------- /test/e2e/embedVm.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import type { Project } from '@stackblitz/sdk'; 3 | 4 | test('vm.getFsSnapshot and vm.applyFsDiff', async ({ page }) => { 5 | await page.goto('/test/pages/blank.html'); 6 | 7 | const project: Project = { 8 | title: 'Test Project', 9 | template: 'html', 10 | files: { 11 | 'index.html': `

Hello World

`, 12 | 'styles.css': `body { color: lime }`, 13 | }, 14 | }; 15 | 16 | // Embed a project and retrieve a snapshot of its files 17 | const fs1 = await page.evaluate( 18 | async ([project]) => { 19 | const vm = await window.StackBlitzSDK.embedProject('embed', project); 20 | const fs = await vm.getFsSnapshot(); 21 | return fs; 22 | }, 23 | [project] 24 | ); 25 | 26 | expect(fs1).not.toBe(null); 27 | expect(fs1).toEqual(project.files); 28 | 29 | // Modify project files using the VM 30 | await page.evaluate(async () => { 31 | const vm = await window.StackBlitzSDK.connect( 32 | document.getElementById('embed') as HTMLIFrameElement 33 | ); 34 | const currentFs = await vm.getFsSnapshot(); 35 | await vm.applyFsDiff({ 36 | destroy: ['styles.css'], 37 | create: { 38 | 'index.html': currentFs!['index.html'].replace('World', 'Playwright'), 39 | 'index.js': `console.log('Yo')`, 40 | }, 41 | }); 42 | }); 43 | 44 | // Check that files were modified 45 | const fs2 = await page.evaluate(async () => { 46 | const vm = await window.StackBlitzSDK.connect( 47 | document.getElementById('embed') as HTMLIFrameElement 48 | ); 49 | return await vm.getFsSnapshot(); 50 | }); 51 | 52 | expect(fs2!['styles.css']).toBeUndefined(); 53 | expect(fs2!['index.js']).toContain('console.log'); 54 | expect(fs2!['index.html']).toBe(`

Hello Playwright

`); 55 | }); 56 | -------------------------------------------------------------------------------- /test/e2e/openProject.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('openProjectId can navigate in same tab', async ({ page }) => { 4 | await page.goto('/test/pages/blank.html'); 5 | 6 | await Promise.all([ 7 | page.waitForNavigation({ waitUntil: 'commit' }), 8 | page.evaluate(() => { 9 | window.StackBlitzSDK.openProjectId('js', { 10 | clickToLoad: true, 11 | newWindow: false, 12 | origin: 'https://example.com', 13 | }); 14 | }), 15 | ]); 16 | 17 | expect(page.url()).toBe('https://example.com/edit/js?ctl=1'); 18 | }); 19 | 20 | test('openProjectId can navigate in new tab', async ({ page }) => { 21 | await page.goto('/test/pages/blank.html'); 22 | const originalUrl = page.url(); 23 | 24 | const [popup] = await Promise.all([ 25 | page.waitForEvent('popup'), 26 | page.evaluate(() => { 27 | window.StackBlitzSDK.openProjectId('js', { 28 | newWindow: true, 29 | openFile: 'App.jsx', 30 | origin: 'https://example.com', 31 | }); 32 | }), 33 | ]); 34 | 35 | const newUrl = await popup.evaluate('location.href'); 36 | 37 | expect(page.url()).toBe(originalUrl); 38 | expect(newUrl).toBe('https://example.com/edit/js?file=App.jsx'); 39 | }); 40 | -------------------------------------------------------------------------------- /test/embed/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mock StackBlitz Embed 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/embed/index.ts: -------------------------------------------------------------------------------- 1 | import { getRequestHandler } from '$test/server/request'; 2 | import { getTestProject } from '$test/unit/utils/project'; 3 | 4 | import './styles.css'; 5 | 6 | function getProjectData() { 7 | const data = document.getElementById('project-data')?.innerHTML || 'null'; 8 | return JSON.parse(data) || getTestProject(); 9 | } 10 | 11 | window.addEventListener('message', (event: MessageEvent) => { 12 | if (event.data.action === 'SDK_INIT' && typeof event.data.id === 'string') { 13 | const project = getProjectData(); 14 | const handleRequest = getRequestHandler(project); 15 | const sdkChannel = new MessageChannel(); 16 | 17 | sdkChannel.port1.onmessage = function (event) { 18 | const message = handleRequest(event.data); 19 | if (message) { 20 | this.postMessage(message); 21 | } 22 | }; 23 | 24 | window.parent.postMessage( 25 | { 26 | action: 'SDK_INIT_SUCCESS', 27 | id: event.data.id, 28 | payload: { 29 | previewOrigin: project.template === 'node' ? null : 'https://test.stackblitz.io', 30 | }, 31 | }, 32 | '*', 33 | [sdkChannel.port2] 34 | ); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /test/embed/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color: #ccc; 3 | background-color: #202327; 4 | } 5 | -------------------------------------------------------------------------------- /test/env.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | StackBlitzSDK: typeof import('@stackblitz/sdk').default; 3 | } 4 | 5 | type StackBlitzSDK = typeof import('@stackblitz/sdk').default; 6 | -------------------------------------------------------------------------------- /test/pages/blank.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @stackblitz/sdk 6 | 7 | 8 | 9 | 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /test/pages/index.ts: -------------------------------------------------------------------------------- 1 | import StackBlitzSDK from '@stackblitz/sdk'; 2 | 3 | (window as any).StackBlitzSDK = StackBlitzSDK; 4 | -------------------------------------------------------------------------------- /test/server/handlers/dependencies.ts: -------------------------------------------------------------------------------- 1 | import { HandleRequest } from '../types'; 2 | 3 | export const SDK_GET_DEPS_SNAPSHOT: HandleRequest = (_data, { error, success, getProject }) => { 4 | const { template, files = {}, dependencies = {} } = getProject(); 5 | 6 | const appDeps: Record = {}; 7 | 8 | if (template === 'node') { 9 | if (!files['package.json']) { 10 | return error(`Could not find package.json in project with template 'node'`); 11 | } 12 | try { 13 | const { dependencies, devDependencies } = JSON.parse(files['package.json']); 14 | Object.assign(appDeps, dependencies, devDependencies); 15 | } catch (err) { 16 | return error(`Could not parse package.json in project with template 'node'`); 17 | } 18 | } else { 19 | const rand = (max: number) => Math.floor(Math.random() * (max + 1)); 20 | for (const name of Object.keys(dependencies || {})) { 21 | appDeps[name] = [rand(12), rand(20), rand(9)].join('.'); 22 | } 23 | } 24 | 25 | return success(appDeps); 26 | }; 27 | -------------------------------------------------------------------------------- /test/server/handlers/editor.ts: -------------------------------------------------------------------------------- 1 | import type { HandleRequest } from '../types'; 2 | import { isTheme, isView, fileToPanes, filterPanes } from '../validation'; 3 | 4 | export const SDK_SET_UI_THEME: HandleRequest = (data, { success, patchState }) => { 5 | const newTheme = data.payload.theme; 6 | 7 | if (isTheme(newTheme)) { 8 | patchState((state) => { 9 | state.theme = newTheme; 10 | }); 11 | } 12 | 13 | return success(); 14 | }; 15 | 16 | export const SDK_SET_UI_VIEW: HandleRequest = (data, { success, patchState }) => { 17 | const newView = data.payload.view; 18 | 19 | if (isView(newView)) { 20 | patchState((state) => { 21 | state.view = newView; 22 | }); 23 | } 24 | 25 | return success(); 26 | }; 27 | 28 | export const SDK_TOGGLE_SIDEBAR: HandleRequest = (data, { success, patchState }) => { 29 | const visible = data.payload.visible; 30 | 31 | if (typeof visible === 'boolean') { 32 | patchState((state) => { 33 | state.sidebarVisible = visible; 34 | }); 35 | } 36 | 37 | return success(); 38 | }; 39 | 40 | export const SDK_OPEN_FILE: HandleRequest = (data, { error, success, getProject, patchState }) => { 41 | const { files } = getProject(); 42 | 43 | const rawPanes = fileToPanes(data.payload.path); 44 | const editorPanes = filterPanes(rawPanes, files); 45 | 46 | // Error if we have zero valid files 47 | if (!editorPanes.length) { 48 | return error( 49 | `No file found for: ${rawPanes 50 | .flat() 51 | .map((p) => `'${p}'`) 52 | .join(', ')}` 53 | ); 54 | } 55 | 56 | patchState((state) => { 57 | state.editorPanes = editorPanes; 58 | }); 59 | 60 | return success(); 61 | }; 62 | 63 | export const SDK_SET_CURRENT_FILE: HandleRequest = ( 64 | data, 65 | { error, success, getProject, patchState } 66 | ) => { 67 | const { path } = data.payload; 68 | const { files } = getProject(); 69 | 70 | // Check that we have this file 71 | if (typeof files[path] !== 'string') { 72 | return error(`File not found: '${path}'`); 73 | } 74 | 75 | patchState((state) => { 76 | state.currentFile = path; 77 | }); 78 | 79 | return success(); 80 | }; 81 | -------------------------------------------------------------------------------- /test/server/handlers/fs.ts: -------------------------------------------------------------------------------- 1 | import type { HandleRequest } from '../types'; 2 | 3 | export const SDK_GET_FS_SNAPSHOT: HandleRequest = (_data, { success, getProject }) => { 4 | return success(getProject().files); 5 | }; 6 | 7 | export const SDK_APPLY_FS_DIFF: HandleRequest = ( 8 | { payload }, 9 | { success, getProject, patchProject } 10 | ) => { 11 | const updatedFiles = { ...getProject().files, ...payload.create }; 12 | for (const path of payload.destroy) { 13 | delete updatedFiles[path]; 14 | } 15 | patchProject((project) => (project.files = updatedFiles)); 16 | 17 | return success(); 18 | }; 19 | -------------------------------------------------------------------------------- /test/server/handlers/init.ts: -------------------------------------------------------------------------------- 1 | import { HandleRequest } from '../types'; 2 | 3 | export const SDK_INIT: HandleRequest = (_data, { success, getProject }) => { 4 | return success({ 5 | previewOrigin: getProject().template === 'node' ? null : 'https://test.stackblitz.io/', 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /test/server/handlers/preview.ts: -------------------------------------------------------------------------------- 1 | import { cleanPreviewPath } from '../validation'; 2 | import type { HandleRequest } from '../types'; 3 | 4 | export const SDK_GET_PREVIEW_URL: HandleRequest = (_data, { error, success, getState }) => { 5 | const { previewUrl } = getState(); 6 | 7 | if (!previewUrl) { 8 | return error('No preview URL found'); 9 | } 10 | 11 | return success({ url: previewUrl }); 12 | }; 13 | 14 | export const SDK_SET_PREVIEW_URL: HandleRequest = ( 15 | data, 16 | { error, success, getProject, getState, patchState } 17 | ) => { 18 | const { template } = getProject(); 19 | const { previewUrl } = getState(); 20 | 21 | const newPath = cleanPreviewPath(data.payload.path); 22 | if (!newPath) { 23 | return error(`Invalid path '${data.payload.path}'`); 24 | } 25 | 26 | if (template === 'node' && !previewUrl) { 27 | return error('Server not running'); 28 | } 29 | 30 | if (previewUrl) { 31 | const newUrl = new URL(previewUrl); 32 | newUrl.pathname = newPath; 33 | patchState((state) => { 34 | state.previewUrl = newUrl.toString(); 35 | }); 36 | } 37 | 38 | return success(); 39 | }; 40 | -------------------------------------------------------------------------------- /test/server/request.ts: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep'; 2 | 3 | import type { Project, ProjectOptions } from '$src/interfaces'; 4 | 5 | import type { 6 | AppState, 7 | AppStateContext, 8 | HandleRequest, 9 | HandleRootRequest, 10 | MessageContext, 11 | RequestData, 12 | ProjectContext, 13 | InitRequestData, 14 | AnyRequestData, 15 | InitResponseData, 16 | } from './types'; 17 | import { fileToPanes, filterPanes } from './validation'; 18 | 19 | import * as dependenciesHandlers from './handlers/dependencies'; 20 | import * as editorHandlers from './handlers/editor'; 21 | import * as fsHandlers from './handlers/fs'; 22 | import * as previewHandlers from './handlers/preview'; 23 | 24 | const handlers: Record = { 25 | ...dependenciesHandlers, 26 | ...editorHandlers, 27 | ...fsHandlers, 28 | ...previewHandlers, 29 | }; 30 | 31 | export function getRequestHandler( 32 | project: Project, 33 | options: ProjectOptions = {} 34 | ): HandleRootRequest { 35 | const projectContext = getProjectContext(project); 36 | const appStateContext = getAppStateContext(project, options); 37 | 38 | return (data) => { 39 | if (isInitRequest(data)) { 40 | const response: InitResponseData = { 41 | action: 'SDK_INIT_SUCCESS', 42 | id: data.id, 43 | payload: { 44 | previewOrigin: 45 | projectContext.getProject().template === 'node' ? null : 'https://test.stackblitz.io', 46 | }, 47 | }; 48 | return response; 49 | } 50 | 51 | if (isVmRequest(data)) { 52 | const context = { 53 | ...appStateContext, 54 | ...projectContext, 55 | ...getMessageContext(data), 56 | }; 57 | if (typeof handlers[data.type] === 'function') { 58 | return handlers[data.type](data, context); 59 | } 60 | return context.error('NOT IMPLEMENTED'); 61 | } 62 | 63 | return null; 64 | }; 65 | } 66 | 67 | function isInitRequest(data: AnyRequestData): data is InitRequestData { 68 | return data.action === 'SDK_INIT' && typeof data.id === 'string'; 69 | } 70 | 71 | function isVmRequest(data: AnyRequestData): data is RequestData { 72 | return typeof data.type === 'string' && data.type.startsWith('SDK_'); 73 | } 74 | 75 | function getMessageContext(data: RequestData): MessageContext { 76 | return { 77 | error(msg) { 78 | return { 79 | type: `${data.type}_FAILURE`, 80 | payload: { __reqid: data.payload.__reqid, __success: false, __error: msg }, 81 | }; 82 | }, 83 | success(payload) { 84 | return { 85 | type: `${data.type}_SUCCESS`, 86 | payload: { __reqid: data.payload.__reqid, __success: true, ...payload }, 87 | }; 88 | }, 89 | }; 90 | } 91 | 92 | export function getProjectContext(originalProject: Project): ProjectContext { 93 | const project = cloneDeep(originalProject); 94 | 95 | return { 96 | getProject() { 97 | return project; 98 | }, 99 | patchProject(patchFn) { 100 | patchFn(project); 101 | }, 102 | }; 103 | } 104 | 105 | export function getAppStateContext(project: Project, options: ProjectOptions): AppStateContext { 106 | const isNode = project.template === 'node'; 107 | 108 | const openFile = options.openFile ?? Object.keys(project.files)[0]; 109 | const panes: AppState['editorPanes'] = filterPanes(fileToPanes(openFile), project.files); 110 | 111 | const state: AppState = { 112 | editorPanes: panes, 113 | currentFile: panes.flat()[0], 114 | previewUrl: isNode ? undefined : 'https://test.stackblitz.io/', 115 | theme: options.theme ?? 'default', 116 | view: options.view ?? 'default', 117 | sidebarVisible: options.showSidebar ?? false, 118 | }; 119 | 120 | return { 121 | getState() { 122 | return state; 123 | }, 124 | patchState(patchFn) { 125 | patchFn(state); 126 | }, 127 | }; 128 | } 129 | -------------------------------------------------------------------------------- /test/server/types.ts: -------------------------------------------------------------------------------- 1 | import type { Project } from '$src/interfaces'; 2 | 3 | export interface InitRequestData { 4 | action: 'SDK_INIT'; 5 | id: string; 6 | } 7 | 8 | export interface InitResponseData { 9 | action: 'SDK_INIT_SUCCESS'; 10 | id: string; 11 | payload: { 12 | previewOrigin: string | null; 13 | }; 14 | } 15 | 16 | export interface RequestData { 17 | type: `SDK_${string}`; 18 | payload: { 19 | __reqid: string; 20 | [key: string]: any; 21 | }; 22 | } 23 | 24 | export type AnyRequestData = Partial; 25 | 26 | export interface ResponseData { 27 | type: string; 28 | payload: { 29 | __reqid: string; 30 | __success: boolean; 31 | __error?: string; 32 | [key: string]: any; 33 | }; 34 | } 35 | 36 | export interface AppState { 37 | currentFile?: string; 38 | editorPanes: string[][]; 39 | previewUrl?: string; 40 | sidebarVisible: boolean; 41 | view: 'default' | 'editor' | 'preview'; 42 | theme: 'default' | 'light' | 'dark'; 43 | } 44 | 45 | export interface AppStateContext { 46 | getState(): AppState; 47 | patchState(patchFn: (state: AppState) => void): void; 48 | } 49 | 50 | export interface ProjectContext { 51 | getProject(): Project; 52 | patchProject(patchFn: (project: Project) => void): void; 53 | } 54 | 55 | export interface MessageContext { 56 | error(msg: string): ResponseData; 57 | success(payload?: Record): ResponseData; 58 | } 59 | 60 | export type HandlerContext = AppStateContext & ProjectContext & MessageContext; 61 | 62 | export type HandleRequest = (data: RequestData, context: HandlerContext) => ResponseData; 63 | 64 | export type HandleRootRequest = (data: AnyRequestData) => InitResponseData | ResponseData | null; 65 | -------------------------------------------------------------------------------- /test/server/validation.ts: -------------------------------------------------------------------------------- 1 | import type { OpenFileOption } from '$src/interfaces'; 2 | 3 | export function isTheme(input: any) { 4 | return ['default', 'dark', 'light'].includes(input); 5 | } 6 | 7 | export function isView(input: any) { 8 | return ['default', 'editor', 'preview'].includes(input); 9 | } 10 | 11 | export function fileToPanes(file: OpenFileOption): string[][] { 12 | const files = Array.isArray(file) ? file : [file]; 13 | return files 14 | .filter((path) => typeof path === 'string') 15 | .map((path) => path.split(',')) 16 | .filter((paths) => paths.length > 0); 17 | } 18 | 19 | export function filterPanes(panes: string[][], files: Record): string[][] { 20 | const fileExists = (path: string) => typeof files[path] === 'string'; 21 | return panes.map((pane) => pane.filter(fileExists)).filter((pane) => pane.length > 0); 22 | } 23 | 24 | export function cleanPreviewPath(input: any): string { 25 | if (typeof input === 'string') { 26 | let path = decodeURIComponent(input).split(/#\?/)[0].trim(); 27 | if (path.length > 0 && !path.startsWith('/')) { 28 | path = '/' + path; 29 | } 30 | return path; 31 | } 32 | return ''; 33 | } 34 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/generate.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`createProjectFrameHTML > generates a HTML document string 1`] = ` 4 | " 5 | 6 | 7 | 8 |
13 | 14 | 15 | " 16 | `; 17 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/lib.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`openProject > injects a form to open project in new page 1`] = ` 4 | NamedNodeMap { 5 | "action": "https://stackblitz.com/run", 6 | "method": "POST", 7 | "style": "display:none!important;", 8 | "target": "_blank", 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/vm.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`vm.getDependencies > node project with package.json 1`] = ` 4 | { 5 | "lodash": "^4", 6 | "rxjs": "^7", 7 | "serve": "^12", 8 | "vite": "^3", 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /test/unit/generate.spec.ts: -------------------------------------------------------------------------------- 1 | /** @vitest-environment happy-dom */ 2 | import { afterEach, describe, expect, test, vi } from 'vitest'; 3 | 4 | import { createProjectForm, createProjectFrameHTML, openNewProject } from '$src/generate'; 5 | import { useMockConsole, restoreConsole } from './utils/console'; 6 | import { formValue, makeContainer, removeContainer } from './utils/dom'; 7 | import { getTestProject } from './utils/project'; 8 | 9 | const cleanUp = () => { 10 | restoreConsole(); 11 | removeContainer(); 12 | }; 13 | 14 | describe('createProjectFrameHTML', () => { 15 | afterEach(cleanUp); 16 | 17 | test('generates a HTML document string', () => { 18 | const html = createProjectFrameHTML(getTestProject()); 19 | expect(html).toBeTypeOf('string'); 20 | expect(html).toMatchSnapshot(); 21 | }); 22 | 23 | test('generated HTML contains a form', () => { 24 | const parent = makeContainer(); 25 | parent.innerHTML = createProjectFrameHTML(getTestProject(), { 26 | view: 'editor', 27 | terminalHeight: 50, 28 | }); 29 | 30 | // Check for form element 31 | const form = parent.querySelector('form')!; 32 | expect(form).toBeInstanceOf(HTMLFormElement); 33 | expect(form.getAttribute('action')).toBe( 34 | 'https://stackblitz.com/run?embed=1&view=editor&terminalHeight=50' 35 | ); 36 | }); 37 | }); 38 | 39 | describe('createProjectForm', () => { 40 | afterEach(cleanUp); 41 | 42 | test('generates a form element', () => { 43 | const project = getTestProject({ 44 | title: 'My Test Project', 45 | dependencies: { cowsay: '1.5.0' }, 46 | settings: { compile: { clearConsole: false } }, 47 | }); 48 | const form = createProjectForm(project); 49 | const value = (name: string) => formValue(form, name); 50 | 51 | // Check input values 52 | expect(value('project[title]')).toBe('My Test Project'); 53 | expect(value('project[description]')).toBe(undefined); 54 | expect(value('project[template]')).toBe(project.template); 55 | expect(value('project[dependencies]')).toBe(JSON.stringify(project.dependencies)); 56 | expect(value('project[settings]')).toBe(JSON.stringify(project.settings)); 57 | expect(value('project[files][package.json]')).toBe(project.files['package.json']); 58 | expect(value('project[files][index.js]')).toBe(project.files['index.js']); 59 | }); 60 | 61 | test('warns on unknown template type', () => { 62 | const console = useMockConsole(); 63 | const warnSpy = vi.spyOn(console, 'warn'); 64 | 65 | const project = getTestProject({ template: 'doesntexist' as any }); 66 | const form = createProjectForm(project); 67 | const value = (name: string) => formValue(form, name); 68 | 69 | // Unknown template value is accepted by triggers a warning 70 | expect(value('project[template]')).toBe('doesntexist'); 71 | expect(warnSpy).toHaveBeenCalledWith( 72 | `Unsupported project.template: must be one of 'angular-cli', 'create-react-app', 'html', 'javascript', 'node', 'polymer', 'typescript', 'vue'` 73 | ); 74 | }); 75 | 76 | test('ignores dependencies for node template', () => { 77 | const console = useMockConsole(); 78 | const warnSpy = vi.spyOn(console, 'warn'); 79 | 80 | const project = getTestProject({ 81 | template: 'node', 82 | dependencies: { serve: '^12' }, 83 | }); 84 | const form = createProjectForm(project); 85 | const value = (name: string) => formValue(form, name); 86 | 87 | // Unknown template value is accepted by triggers a warning 88 | expect(value('project[template]')).toBe('node'); 89 | expect(value('project[dependencies]')).toBe(undefined); 90 | expect(warnSpy).toHaveBeenCalledWith( 91 | `Invalid project.dependencies: dependencies must be provided as a 'package.json' file when using the 'node' template.` 92 | ); 93 | }); 94 | }); 95 | 96 | describe('openNewProject', () => { 97 | test('cleans up after itself', () => { 98 | const project = getTestProject(); 99 | 100 | expect(document.body.children.length).toBe(0); 101 | expect(() => openNewProject(project)).not.toThrow(); 102 | expect(document.body.children.length).toBe(0); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/unit/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | /** @vitest-environment happy-dom */ 2 | import { afterEach, describe, expect, test } from 'vitest'; 3 | 4 | import { DEFAULT_FRAME_HEIGHT } from '$src/constants'; 5 | import { embedUrl, findElement, genID, openTarget, openUrl, replaceAndEmbed } from '$src/helpers'; 6 | import { h, makeContainer, removeContainer } from './utils/dom'; 7 | 8 | describe('embedUrl', () => { 9 | test('works with custom origins', () => { 10 | expect(embedUrl('/edit/test', { origin: 'http://localhost:8000' })).toBe( 11 | 'http://localhost:8000/edit/test?embed=1' 12 | ); 13 | expect(embedUrl('/edit/test', { origin: 'http://localhost:8000/' })).toBe( 14 | 'http://localhost:8000/edit/test?embed=1' 15 | ); 16 | }); 17 | 18 | test('turns config into URL query parameters', () => { 19 | expect( 20 | embedUrl('/edit/test', { 21 | clickToLoad: true, 22 | openFile: 'index.js', 23 | theme: 'dark', 24 | crossOriginIsolated: true, 25 | }) 26 | ).toBe('https://stackblitz.com/edit/test?embed=1&ctl=1&file=index.js&theme=dark&corp=1'); 27 | }); 28 | 29 | test('allows removing the embed=1 query parameter', () => { 30 | expect(embedUrl('/edit/test', { forceEmbedLayout: false })).toBe( 31 | 'https://stackblitz.com/edit/test' 32 | ); 33 | }); 34 | }); 35 | 36 | describe('findElement', () => { 37 | afterEach(removeContainer); 38 | 39 | test('returns HTML elements as-is', () => { 40 | const div = h('div'); 41 | expect(findElement(div)).toBe(div); 42 | }); 43 | 44 | test('finds HTML elements by id', () => { 45 | const div = makeContainer().appendChild(h('div', { id: 'test' })); 46 | expect(findElement('test')).toBe(div); 47 | }); 48 | 49 | test('throws when element cannot be found', () => { 50 | expect(() => findElement('test')).toThrowError("Could not find element with id 'test'"); 51 | }); 52 | 53 | test('throws with invalid input', () => { 54 | expect(() => findElement(Math.PI as any)).toThrowError('Invalid element: 3.141592653589793'); 55 | }); 56 | }); 57 | 58 | describe('genID', () => { 59 | function findCollisions(generator: () => string, count = 0) { 60 | const generated: Record = {}; 61 | const collisions: string[] = []; 62 | while (count > 0) { 63 | const item = generator(); 64 | if (generated[item]) collisions.push(item); 65 | generated[item] = true; 66 | count--; 67 | } 68 | return { 69 | all: Object.keys(generated), 70 | collisions, 71 | }; 72 | } 73 | 74 | test('doesn’t produce collisions for a low number of values', () => { 75 | const RUN_COUNT = 1_000; 76 | const { all, collisions } = findCollisions(genID, RUN_COUNT); 77 | expect(all.length).toBe(RUN_COUNT); 78 | expect(collisions.length).toBe(0); 79 | }); 80 | }); 81 | 82 | describe('openTarget', () => { 83 | test('translates the newWindow option', () => { 84 | expect(openTarget()).toBe('_blank'); 85 | expect(openTarget({ newWindow: true })).toBe('_blank'); 86 | expect(openTarget({ newWindow: false })).toBe('_self'); 87 | }); 88 | }); 89 | 90 | describe('openUrl', () => { 91 | test('works with custom origins', () => { 92 | expect(openUrl('/edit/test', { origin: 'http://localhost:8000/' })).toBe( 93 | 'http://localhost:8000/edit/test' 94 | ); 95 | }); 96 | 97 | test('turns config into URL query parameters', () => { 98 | expect(openUrl('/edit/test', { clickToLoad: true, openFile: ['index.js', 'README.md'] })).toBe( 99 | 'https://stackblitz.com/edit/test?ctl=1&file=index.js&file=README.md' 100 | ); 101 | }); 102 | 103 | test('allows adding the embed=1 query parameter', () => { 104 | expect(openUrl('/edit/test', { forceEmbedLayout: true })).toBe( 105 | 'https://stackblitz.com/edit/test?embed=1' 106 | ); 107 | }); 108 | }); 109 | 110 | describe('replaceAndEmbed', () => { 111 | afterEach(removeContainer); 112 | 113 | test('throws with invalid input', () => { 114 | const target = h('div') as HTMLDivElement; 115 | const iframe = h('iframe', { src: 'about:blank' }) as HTMLIFrameElement; 116 | 117 | expect(() => { 118 | replaceAndEmbed(target, undefined as any); 119 | }).toThrowError('Invalid Element'); 120 | 121 | expect(() => { 122 | replaceAndEmbed(undefined as any, iframe); 123 | }).toThrowError('Invalid Element'); 124 | 125 | // Still throws because the target element has not parent 126 | expect(() => { 127 | replaceAndEmbed(target, iframe); 128 | }).toThrowError('Invalid Element'); 129 | 130 | // Attach target element to document 131 | makeContainer().append(target); 132 | expect(() => { 133 | replaceAndEmbed(target, iframe); 134 | }).not.toThrow(); 135 | }); 136 | 137 | test('replaces target element with iframe', () => { 138 | const embedSrc = 'https://stackblitz.com/edit/test?embed=1'; 139 | const target = h('div', { 140 | id: 'embed', 141 | class: 'test', 142 | 'data-old': 'true', 143 | }); 144 | const iframe = h('iframe', { 145 | src: embedSrc, 146 | 'data-new': 'true', 147 | }) as HTMLIFrameElement; 148 | 149 | makeContainer().append(target); 150 | replaceAndEmbed(target!, iframe); 151 | const found = document.getElementById('embed')!; 152 | 153 | expect(found).toBe(iframe); 154 | expect(found.className).toBe('test'); 155 | expect(found.getAttribute('src')).toBe(embedSrc); 156 | expect(found.getAttribute('data-old')).toBe(null); 157 | expect(found.getAttribute('data-new')).toBe('true'); 158 | }); 159 | 160 | test('sets default iframe dimensions', () => { 161 | const div = makeContainer().appendChild(h('div')); 162 | const iframe = h('iframe', { src: 'https://example.com/' }); 163 | 164 | replaceAndEmbed(div, iframe as HTMLIFrameElement); 165 | 166 | expect(iframe.getAttribute('height')).toBe(String(DEFAULT_FRAME_HEIGHT)); 167 | expect(iframe.getAttribute('width')).toBe(null); 168 | expect(iframe.style.width).toBe('100%'); 169 | }); 170 | 171 | test('sets iframe dimensions from EmbedOptions', () => { 172 | const div = makeContainer().appendChild(h('div')); 173 | const iframe = h('iframe', { src: 'https://example.com/' }); 174 | 175 | replaceAndEmbed(div, iframe as HTMLIFrameElement, { width: 500, height: 500 }); 176 | 177 | expect(iframe.getAttribute('height')).toBe('500'); 178 | expect(iframe.getAttribute('width')).toBe('500'); 179 | expect(iframe.getAttribute('style')).toBe(null); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /test/unit/lib.spec.ts: -------------------------------------------------------------------------------- 1 | /** @vitest-environment happy-dom */ 2 | import { describe, expect, test, vi } from 'vitest'; 3 | 4 | import { connect, openGithubProject, openProject, openProjectId } from '$src/lib'; 5 | import { getTestProject } from './utils/project'; 6 | 7 | /** 8 | * Cannot test a working use case for connect or or all the embed* methods using happy-dom, 9 | * due to iframes not having a contentWindow and Window objects not having postMessage 10 | */ 11 | describe('connect', () => { 12 | test('rejects if target is not an iframe', async () => { 13 | const div = document.createElement('div'); 14 | await expect(connect(div as any)).rejects.toBe(`Provided element is not an iframe.`); 15 | }); 16 | }); 17 | 18 | describe('openProject', () => { 19 | test('injects a form to open project in new page', () => { 20 | const project = getTestProject(); 21 | 22 | // Spy on openProject doing DOM mutations 23 | let form: HTMLFormElement; 24 | const observerCb = vi.fn(([record]: MutationRecord[]) => { 25 | if (!record || form) return; 26 | record.addedNodes.forEach((node) => { 27 | if (node instanceof HTMLFormElement) { 28 | form = node; 29 | } 30 | }); 31 | }); 32 | const observer = new MutationObserver(observerCb); 33 | observer.observe(document.body, { 34 | childList: true, 35 | attributes: false, 36 | subtree: true, 37 | }); 38 | 39 | expect(() => openProject(project)).not.toThrow(); 40 | 41 | // We expect to have seen 2 DOM mutations (add then remove form) 42 | expect(observerCb).toHaveBeenCalledTimes(2); 43 | 44 | // We expect to have seen a form 45 | expect(form!).toBeInstanceOf(HTMLFormElement); 46 | expect(form!.attributes).toMatchSnapshot(); 47 | 48 | observer.disconnect(); 49 | observerCb.mockRestore(); 50 | }); 51 | }); 52 | 53 | describe('openProjectId', () => { 54 | test('calls window.open', () => { 55 | const openSpy = vi.spyOn(window, 'open'); 56 | 57 | openProjectId('js'); 58 | expect(openSpy).toBeCalledWith('https://stackblitz.com/edit/js', '_blank'); 59 | 60 | openProjectId('js', { 61 | origin: 'https://example.com', 62 | newWindow: false, 63 | openFile: 'src/index.js', 64 | }); 65 | expect(openSpy).toBeCalledWith('https://example.com/edit/js?file=src%2Findex.js', '_self'); 66 | 67 | openSpy.mockRestore(); 68 | }); 69 | }); 70 | 71 | describe('openGithubProject', () => { 72 | test('calls window.open', () => { 73 | const openSpy = vi.spyOn(window, 'open'); 74 | 75 | openGithubProject('stackblitz/sdk'); 76 | expect(openSpy).toBeCalledWith('https://stackblitz.com/github/stackblitz/sdk', '_blank'); 77 | 78 | openGithubProject('stackblitz/docs', { newWindow: false, hideExplorer: true }); 79 | expect(openSpy).toBeCalledWith( 80 | 'https://stackblitz.com/github/stackblitz/docs?hideExplorer=1', 81 | '_self' 82 | ); 83 | 84 | openSpy.mockRestore(); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/unit/params.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { 4 | ParamOptions, 5 | booleanParam, 6 | buildParams, 7 | enumParam, 8 | generators, 9 | percentParam, 10 | stringParams, 11 | trueParam, 12 | } from '$src/params'; 13 | 14 | describe('params formats', () => { 15 | test('trueParam accepts true only', () => { 16 | expect(trueParam('_test')).toBe(''); 17 | expect(trueParam('_test', 1 as any)).toBe(''); 18 | expect(trueParam('_test', false)).toBe(''); 19 | expect(trueParam('_test', true)).toBe('_test=1'); 20 | }); 21 | 22 | test('booleanParam accepts true and false', () => { 23 | expect(booleanParam('_test')).toBe(''); 24 | expect(booleanParam('_test', 'yes' as any)).toBe(''); 25 | expect(booleanParam('_test', false)).toBe('_test=0'); 26 | expect(booleanParam('_test', true)).toBe('_test=1'); 27 | }); 28 | 29 | test('percentParam clamps and rounds number values', () => { 30 | expect(percentParam('_test')).toBe(''); 31 | expect(percentParam('_test', '50%' as any)).toBe(''); 32 | expect(percentParam('_test', 0 / 0)).toBe(''); 33 | // It accepts integers 34 | expect(percentParam('_test', 0)).toBe('_test=0'); 35 | expect(percentParam('_test', 33)).toBe('_test=33'); 36 | expect(percentParam('_test', 100)).toBe('_test=100'); 37 | // It rounds values 38 | expect(percentParam('_test', Math.PI)).toBe('_test=3'); 39 | expect(percentParam('_test', 49.5001)).toBe('_test=50'); 40 | // It clamps values 41 | expect(percentParam('_test', -50)).toBe('_test=0'); 42 | expect(percentParam('_test', 150)).toBe('_test=100'); 43 | }); 44 | 45 | test('stringParams', () => { 46 | expect(stringParams('_test', '')).toBe(''); 47 | expect(stringParams('_test', 'hello')).toBe('_test=hello'); 48 | expect(stringParams('_test', 'a/../b?c&d')).toBe('_test=a%2F..%2Fb%3Fc%26d'); 49 | expect(stringParams('_test', ['hello', 'beautiful', 'world'])).toBe( 50 | '_test=hello&_test=beautiful&_test=world' 51 | ); 52 | }); 53 | 54 | test('enumParam drops invalid options', () => { 55 | const options = ['yes', 'no', 'maybe?']; 56 | expect(enumParam('_test')).toBe(''); 57 | expect(enumParam('_test', 'yes', options)).toBe('_test=yes'); 58 | expect(enumParam('_test', 'nope', options)).toBe(''); 59 | expect(enumParam('_test', 'maybe?', options)).toBe('_test=maybe%3F'); 60 | }); 61 | }); 62 | 63 | describe('buildParams', () => { 64 | test('ignores unknown options', () => { 65 | expect(buildParams()).toBe(''); 66 | expect(buildParams({})).toBe(''); 67 | expect(buildParams({ boop: 'boop', height: 50 } as any)).toBe(''); 68 | expect(buildParams({ boop: 'boop', showSidebar: true } as any)).toBe('?showSidebar=1'); 69 | }); 70 | 71 | test('ignores unknown options', () => { 72 | expect(buildParams({})).toBe(''); 73 | expect(buildParams({ boop: 'boop', height: 50 } as any)).toBe(''); 74 | expect(buildParams({ boop: 'boop', showSidebar: true } as any)).toBe('?showSidebar=1'); 75 | expect(buildParams({ origin: 'https://example.com' } as any)).toBe(''); 76 | }); 77 | 78 | test('doesn’t output undefined or default values', () => { 79 | const options: ParamOptions = { 80 | clickToLoad: false, 81 | devToolsHeight: NaN, 82 | forceEmbedLayout: false, 83 | hideDevTools: false, 84 | hideExplorer: false, 85 | hideNavigation: false, 86 | openFile: '', 87 | organization: undefined, 88 | showSidebar: undefined, 89 | sidebarView: 'default', 90 | startScript: undefined, 91 | terminalHeight: NaN, 92 | theme: 'default', 93 | view: 'default', 94 | zenMode: false, 95 | crossOriginIsolated: false, 96 | }; 97 | // Check that we are testing all options 98 | expect(Object.keys(options).sort()).toStrictEqual(Object.keys(generators).sort()); 99 | // Check that default and undefined values don't output anything 100 | expect(buildParams(options)).toBe(''); 101 | }); 102 | 103 | test('outputs non-default values for known options', () => { 104 | const options: ParamOptions = { 105 | clickToLoad: true, 106 | devToolsHeight: 100, 107 | forceEmbedLayout: true, 108 | hideDevTools: true, 109 | hideExplorer: true, 110 | hideNavigation: true, 111 | openFile: ['src/index.js,src/styles.css', 'package.json'], 112 | organization: { name: 'stackblitz', provider: 'github' }, 113 | showSidebar: true, 114 | sidebarView: 'search', 115 | startScript: 'dev:serve', 116 | terminalHeight: 50, 117 | theme: 'light', 118 | view: 'preview', 119 | zenMode: true, 120 | crossOriginIsolated: true, 121 | }; 122 | // Check that we are testing all options 123 | expect(Object.keys(options).sort()).toStrictEqual(Object.keys(generators).sort()); 124 | // Check that all values end up in the query string 125 | // (Comparing sorted arrays instead of strings to make failures readable.) 126 | expect(buildParams(options).split('&').sort()).toStrictEqual( 127 | [ 128 | '?ctl=1', 129 | 'devtoolsheight=100', 130 | 'embed=1', 131 | 'hidedevtools=1', 132 | 'hideExplorer=1', 133 | 'hideNavigation=1', 134 | 'file=src%2Findex.js%2Csrc%2Fstyles.css', 135 | 'file=package.json', 136 | 'orgName=stackblitz', 137 | 'orgProvider=github', 138 | 'showSidebar=1', 139 | 'sidebarView=search', 140 | 'startScript=dev%3Aserve', 141 | 'terminalHeight=50', 142 | 'theme=light', 143 | 'view=preview', 144 | 'zenMode=1', 145 | 'corp=1', 146 | ].sort() 147 | ); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /test/unit/rdc.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { RDC } from '$src/rdc'; 4 | 5 | function getRdc({ error, delay }: { error?: string; delay?: number } = {}) { 6 | const channel = new MessageChannel(); 7 | 8 | channel.port1.onmessage = function (event) { 9 | const message = getResponseMessage({ ...event.data, error }); 10 | setTimeout(() => this.postMessage(message), delay); 11 | }; 12 | 13 | return new RDC(channel.port2); 14 | } 15 | 16 | function getResponseMessage({ 17 | type, 18 | payload, 19 | error, 20 | }: { 21 | type: string; 22 | payload: any; 23 | error?: string; 24 | }) { 25 | const response = { 26 | type: `${type}_${error ? 'ERROR' : 'SUCCESS'}`, 27 | payload: { 28 | __reqid: payload.__reqid, 29 | __success: !error, 30 | }, 31 | }; 32 | if (error) { 33 | (response.payload as any).__error = error; 34 | } 35 | if (payload.value) { 36 | (response.payload as any).value = payload.value; 37 | } 38 | return response; 39 | } 40 | 41 | describe('RDC', () => { 42 | test('receives a success value', async () => { 43 | const rdc = getRdc({}); 44 | await expect( 45 | rdc.request({ 46 | type: 'TEST', 47 | payload: {}, 48 | }) 49 | ).resolves.toBe(null); 50 | await expect( 51 | rdc.request({ 52 | type: 'TEST', 53 | payload: { value: 100 }, 54 | }) 55 | ).resolves.toEqual({ value: 100 }); 56 | }); 57 | 58 | test('receives a value after a delay', async () => { 59 | const rdc = getRdc({ delay: 20 }); 60 | await expect( 61 | rdc.request({ 62 | type: 'TEST', 63 | payload: {}, 64 | }) 65 | ).resolves.toBe(null); 66 | }); 67 | 68 | test('receives an error message', async () => { 69 | const rdc = getRdc({ error: 'something went wrong' }); 70 | await expect(rdc.request({ type: 'TEST', payload: {} })).rejects.toBe( 71 | 'TEST_ERROR: something went wrong' 72 | ); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/unit/utils/console.ts: -------------------------------------------------------------------------------- 1 | const originalConsole = globalThis.console; 2 | 3 | const mockConsole: Partial = { 4 | log(...args) {}, 5 | warn(...args) {}, 6 | error(...args) {}, 7 | table(...args) {}, 8 | }; 9 | 10 | export function useMockConsole() { 11 | globalThis.console = mockConsole as typeof originalConsole; 12 | return mockConsole; 13 | } 14 | 15 | export function restoreConsole() { 16 | globalThis.console = originalConsole; 17 | } 18 | -------------------------------------------------------------------------------- /test/unit/utils/dom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Build a HTMLElement with attributes 3 | */ 4 | export function h(name: string, attrs: Record = {}) { 5 | const element = document.createElement(name); 6 | for (const [key, val] of Object.entries(attrs)) { 7 | element.setAttribute(key, val); 8 | } 9 | return element; 10 | } 11 | 12 | export function formValue(form: HTMLFormElement, name: string): string | undefined { 13 | const inputs = form.querySelectorAll('[name]'); 14 | for (const input of inputs) { 15 | if (input.name === name) return input.value; 16 | } 17 | } 18 | 19 | const TEST_CONTAINER_ID = '__TEST_CONTAINER__'; 20 | 21 | function findContainer(): HTMLDivElement | null { 22 | return document.getElementById(TEST_CONTAINER_ID) as HTMLDivElement | null; 23 | } 24 | 25 | export function makeContainer(): HTMLDivElement { 26 | let container = findContainer(); 27 | if (!container) { 28 | container = h('div', { id: TEST_CONTAINER_ID }) as HTMLDivElement; 29 | document.body.append(container); 30 | } 31 | return container; 32 | } 33 | 34 | export function removeContainer() { 35 | findContainer()?.remove(); 36 | } 37 | -------------------------------------------------------------------------------- /test/unit/utils/project.ts: -------------------------------------------------------------------------------- 1 | import type { Project } from '$src/index'; 2 | 3 | export function getTestProject(project?: Partial): Project { 4 | return { 5 | title: 'Test Project', 6 | template: 'javascript', 7 | files: { 8 | 'package.json': `{ 9 | "dependencies": {"cowsay": "1.5.0"} 10 | }`, 11 | 'index.js': `import cowsay from 'cowsay'; 12 | console.log(cowsay('Hello world!')); 13 | `, 14 | }, 15 | ...project, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /test/unit/vm.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import type { Project, FsDiff } from '$src/index'; 4 | import { VM } from '$src/vm'; 5 | import { getRequestHandler } from '$test/server/request'; 6 | import { getTestProject } from '$test/unit/utils/project'; 7 | 8 | async function getVm(project: Project, { delay = 0 }: { delay?: number } = {}) { 9 | const channel = new MessageChannel(); 10 | const handleRequest = getRequestHandler(project); 11 | 12 | channel.port2.onmessage = function (event) { 13 | const response = handleRequest(event.data); 14 | setTimeout(() => this.postMessage(response), delay); 15 | }; 16 | 17 | return new VM(channel.port1, { 18 | previewOrigin: project.template === 'node' ? undefined : 'https://test.stackblitz.io', 19 | }); 20 | } 21 | 22 | describe('vm.getFsSnapshot', () => { 23 | test('returns null with no files', async () => { 24 | const files: Project['files'] = {}; 25 | const vm = await getVm(getTestProject({ files })); 26 | 27 | await expect(vm.getFsSnapshot()).resolves.toBe(null); 28 | }); 29 | 30 | test('returns files', async () => { 31 | const files: Project['files'] = { a: 'aaaa', b: 'bbbb' }; 32 | const vm = await getVm(getTestProject({ files })); 33 | 34 | await expect(vm.getFsSnapshot()).resolves.toEqual(files); 35 | }); 36 | }); 37 | 38 | describe('vm.applyFsDiff', () => { 39 | test('throws on invalid diff', async () => { 40 | const vm = await getVm(getTestProject()); 41 | 42 | expect(() => vm.applyFsDiff(null as any)).toThrowError( 43 | 'Invalid diff object: expected diff.create to be an object.' 44 | ); 45 | 46 | expect(() => vm.applyFsDiff({ destroy: [] } as any)).toThrowError( 47 | 'Invalid diff object: expected diff.create to be an object.' 48 | ); 49 | 50 | expect(() => vm.applyFsDiff({ create: {} } as any)).toThrowError( 51 | 'Invalid diff object: expected diff.destroy to be an array.' 52 | ); 53 | }); 54 | 55 | test('can add a file', async () => { 56 | const files: Project['files'] = { 57 | 'index.js': 'Console.warn("Hello world!")', 58 | }; 59 | const diff: FsDiff = { 60 | create: { 61 | 'new.js': '/* Hello */', 62 | }, 63 | destroy: [], 64 | }; 65 | 66 | const vm = await getVm(getTestProject({ files })); 67 | await expect(vm.applyFsDiff(diff)).resolves.toBe(null); 68 | 69 | const snapshot = vm.getFsSnapshot(); 70 | await expect(snapshot.then((files) => Object.keys(files!).length)).resolves.toBe(2); 71 | await expect(snapshot.then((files) => files!['index.js'])).resolves.toBe(files['index.js']); 72 | await expect(snapshot.then((files) => files!['new.js'])).resolves.toBe(diff.create['new.js']); 73 | }); 74 | 75 | test('can delete a file', async () => { 76 | const files: Project['files'] = { 77 | 'src/index.js': 'Console.warn("Hello world!")', 78 | }; 79 | const diff: FsDiff = { 80 | create: {}, 81 | destroy: ['src/index.js'], 82 | }; 83 | const vm = await getVm(getTestProject({ files })); 84 | 85 | await expect(vm.applyFsDiff(diff)).resolves.toBe(null); 86 | await expect(vm.getFsSnapshot()).resolves.toBe(null); 87 | }); 88 | }); 89 | 90 | describe('vm.getDependencies', () => { 91 | test('project with no dependencies', async () => { 92 | const project = getTestProject({ dependencies: undefined }); 93 | const vm = await getVm(project); 94 | 95 | await expect(vm.getDependencies()).resolves.toBe(null); 96 | }); 97 | 98 | test('project with dependencies', async () => { 99 | const project = getTestProject({ 100 | dependencies: { 101 | lodash: '^4.17.10', 102 | rxjs: '^7.5.0', 103 | }, 104 | }); 105 | const vm = await getVm(project); 106 | const depNames = vm.getDependencies().then((deps) => Object.keys(deps!)); 107 | 108 | // We should get original dependencies 109 | await expect(depNames).resolves.toContain('lodash'); 110 | await expect(depNames).resolves.toContain('rxjs'); 111 | // … but with different version specifiers 112 | await expect(vm.getDependencies()).resolves.not.toContain(project.dependencies); 113 | }); 114 | 115 | test('node project with no package.json', async () => { 116 | const project = getTestProject({ 117 | template: 'node', 118 | files: {}, 119 | }); 120 | const vm = await getVm(project); 121 | 122 | await expect(vm.getDependencies()).rejects.toBe( 123 | `SDK_GET_DEPS_SNAPSHOT_FAILURE: Could not find package.json in project with template 'node'` 124 | ); 125 | }); 126 | 127 | test('node project with invalid package.json', async () => { 128 | const project = getTestProject({ 129 | template: 'node', 130 | files: { 'package.json': '/* whoops */' }, 131 | }); 132 | const vm = await getVm(project); 133 | 134 | await expect(vm.getDependencies()).rejects.toBe( 135 | `SDK_GET_DEPS_SNAPSHOT_FAILURE: Could not parse package.json in project with template 'node'` 136 | ); 137 | }); 138 | 139 | test('node project with package.json', async () => { 140 | const packageJson = { 141 | dependencies: { lodash: '^4', rxjs: '^7' }, 142 | devDependencies: { serve: '^12', vite: '^3' }, 143 | optionalDependencies: { cowsay: '^1.5' }, 144 | }; 145 | const project = getTestProject({ 146 | template: 'node', 147 | files: { 148 | 'package.json': JSON.stringify(packageJson), 149 | }, 150 | }); 151 | const vm = await getVm(project); 152 | const deps = vm.getDependencies(); 153 | 154 | // We should get dependencies and devDependencies merged, with original version specifiers 155 | await expect(deps).resolves.toContain(packageJson.dependencies); 156 | await expect(deps).resolves.toContain(packageJson.devDependencies); 157 | await expect(deps).resolves.toMatchSnapshot(); 158 | }); 159 | }); 160 | 161 | describe('vm.editor', () => { 162 | test('can change the color theme', async () => { 163 | const vm = await getVm(getTestProject()); 164 | await expect(vm.editor.setTheme('light')).resolves.toBe(null); 165 | await expect(vm.editor.setTheme('dark')).resolves.toBe(null); 166 | await expect(vm.editor.setTheme('default')).resolves.toBe(null); 167 | }); 168 | 169 | test('can change the UI view', async () => { 170 | const vm = await getVm(getTestProject()); 171 | await expect(vm.editor.setView('preview')).resolves.toBe(null); 172 | await expect(vm.editor.setView('editor')).resolves.toBe(null); 173 | await expect(vm.editor.setView('default')).resolves.toBe(null); 174 | }); 175 | 176 | test('can show the sidebar', async () => { 177 | const vm = await getVm(getTestProject()); 178 | await expect(vm.editor.showSidebar(true)).resolves.toBe(null); 179 | await expect(vm.editor.showSidebar(false)).resolves.toBe(null); 180 | }); 181 | 182 | test('can change open files', async () => { 183 | const files = { 184 | 'index.js': 'console.log("Hello there!")', 185 | 'README.md': '# My Cool Project', 186 | }; 187 | const vm = await getVm(getTestProject({ files })); 188 | 189 | await expect(vm.editor.openFile('README.md')).resolves.toBe(null); 190 | await expect(vm.editor.openFile(['index.js,README.md', 'README.md'])).resolves.toBe(null); 191 | }); 192 | 193 | test('cannot open non-existing files', async () => { 194 | const files = { a: 'aaa', b: 'bbb' }; 195 | const vm = await getVm(getTestProject({ files })); 196 | 197 | // But we get an error if there's no match at all 198 | await expect(vm.editor.openFile('foo.bar')).rejects.toBe( 199 | `SDK_OPEN_FILE_FAILURE: No file found for: 'foo.bar'` 200 | ); 201 | 202 | // Mix of existing and non-existing files still resolves 203 | await expect(vm.editor.openFile(['index.js,b,README.md', 'package.json,a'])).resolves.toBe( 204 | null 205 | ); 206 | 207 | // But we get an error if there's no match at all 208 | await expect(vm.editor.openFile(['index.js', 'foo.bar'])).rejects.toBe( 209 | `SDK_OPEN_FILE_FAILURE: No file found for: 'index.js', 'foo.bar'` 210 | ); 211 | }); 212 | 213 | test('can change open files', async () => { 214 | const files = { 'README.md': 'Hi.', '.stackblitzrc': '{}' }; 215 | const vm = await getVm(getTestProject({ files })); 216 | 217 | await expect(vm.editor.setCurrentFile('README.md')).resolves.toBe(null); 218 | await expect(vm.editor.setCurrentFile('.gitignore')).rejects.toBe( 219 | `SDK_SET_CURRENT_FILE_FAILURE: File not found: '.gitignore'` 220 | ); 221 | }); 222 | }); 223 | 224 | describe('vm.preview', () => { 225 | test('has origin', async () => { 226 | const vm = await getVm(getTestProject()); 227 | expect(vm.preview.origin).toBe('https://test.stackblitz.io'); 228 | }); 229 | 230 | test('has no origin (node project)', async () => { 231 | const vm = await getVm(getTestProject({ template: 'node' })); 232 | expect(vm.preview.origin).toBe(null); 233 | }); 234 | 235 | test('has preview URL', async () => { 236 | const vm = await getVm(getTestProject()); 237 | await expect(vm.preview.getUrl()).resolves.toBeTypeOf('string'); 238 | }); 239 | 240 | test('has no preview URL (node project)', async () => { 241 | const vm = await getVm( 242 | getTestProject({ 243 | template: 'node', 244 | }) 245 | ); 246 | await expect(vm.preview.getUrl()).rejects.toBe( 247 | `SDK_GET_PREVIEW_URL_FAILURE: No preview URL found` 248 | ); 249 | }); 250 | 251 | test('can set preview URL', async () => { 252 | const vm = await getVm(getTestProject({ template: 'javascript' })); 253 | 254 | // Request to modify worked 255 | await expect(vm.preview.setUrl('/about')).resolves.toBe(null); 256 | 257 | // So the URL is now changed 258 | await expect(vm.preview.getUrl()).resolves.toBe('https://test.stackblitz.io/about'); 259 | }); 260 | 261 | test('can only set a path', async () => { 262 | const vm = await getVm(getTestProject({ template: 'javascript' })); 263 | 264 | expect(() => vm.preview.setUrl('https://example.com/')).toThrowError( 265 | `Invalid argument: expected a path starting with '/', got 'https://example.com/'` 266 | ); 267 | 268 | expect(() => vm.preview.setUrl('about/us')).toThrowError( 269 | `Invalid argument: expected a path starting with '/', got 'about/us'` 270 | ); 271 | }); 272 | 273 | test('cannot set preview URL (node project)', async () => { 274 | const vm = await getVm(getTestProject({ template: 'node' })); 275 | 276 | await expect(vm.preview.setUrl('/about')).rejects.toBe( 277 | 'SDK_SET_PREVIEW_URL_FAILURE: Server not running' 278 | ); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "moduleResolution": "node", 5 | "allowSyntheticDefaultImports": true, 6 | "target": "es2018", 7 | "lib": ["es2018", "DOM", "DOM.Iterable"], 8 | "strict": true, 9 | "outDir": "./temp/tsc" 10 | }, 11 | "references": [ 12 | { 13 | "path": "./tsconfig.lib.json" 14 | }, 15 | { 16 | "path": "./tsconfig.test.json" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "composite": true, 6 | "tsBuildInfoFile": "./temp/tsbuildinfo", 7 | "declaration": true, 8 | "declarationDir": "./types", 9 | "emitDeclarationOnly": true, 10 | "removeComments": false 11 | }, 12 | "include": ["./src"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "tsBuildInfoFile": "./temp/tsbuildinfo", 6 | "paths": { 7 | // Use local sources when demo and test pages import @stackblitz/sdk 8 | "@stackblitz/sdk": ["./src/index.ts"], 9 | "$src/*": ["./src/*"], 10 | "$test/*": ["./test/*"] 11 | } 12 | }, 13 | "include": ["./examples", "./src", "./test"] 14 | } 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import ReplacePlugin from '@rollup/plugin-replace'; 2 | import bodyParser from 'body-parser'; 3 | import fs from 'node:fs/promises'; 4 | import path from 'node:path'; 5 | import querystring from 'node:querystring'; 6 | import type { Plugin, UserConfig, ViteDevServer } from 'vite'; 7 | import { defineConfig } from 'vite'; 8 | import TSConfigPaths from 'vite-tsconfig-paths'; 9 | 10 | export default defineConfig(({ mode }) => { 11 | if (!['lib', 'dev', 'test', 'e2e'].includes(mode)) { 12 | throw new Error(`Expected --mode with value 'lib', 'dev', 'test' or 'e2e'`); 13 | } 14 | 15 | if (mode === 'lib') { 16 | return libConfig(); 17 | } 18 | 19 | return testConfig(mode); 20 | }); 21 | 22 | /** 23 | * Config for building the SDK bundles 24 | */ 25 | function libConfig(): UserConfig { 26 | return { 27 | plugins: [replaceOrigin()], 28 | build: { 29 | target: 'es2020', 30 | outDir: 'bundles', 31 | emptyOutDir: true, 32 | sourcemap: true, 33 | minify: 'esbuild', 34 | lib: { 35 | name: 'StackBlitzSDK', 36 | entry: 'src/index.ts', 37 | formats: ['cjs', 'es', 'umd'], 38 | // Override the default file names used by Vite's “Library Mode” 39 | // (https://vitejs.dev/guide/build.html#library-mode) for backwards 40 | // compatibility with microbundle's output. 41 | fileName: (format, _name) => { 42 | let suffix: string = ''; 43 | if (format === 'es') suffix = '.m'; 44 | if (format === 'umd') suffix = '.umd'; 45 | return `sdk${suffix}.js`; 46 | }, 47 | }, 48 | }, 49 | esbuild: { 50 | // Whitespace will not be minified for our ESM build (sdk.m.js) 51 | // because of this restriction in Vite's esbuild plugin: 52 | // https://github.com/vitejs/vite/blob/2219427f4224d75f63a1e1a0af61b32c7854604e/packages/vite/src/node/plugins/esbuild.ts#L348-L351 53 | // Instead of having mixed minified and unminified bundles, let’s ship 54 | // unminified bundles because they’re small enough (especially gzipped). 55 | minifyIdentifiers: false, 56 | minifySyntax: false, 57 | minifyWhitespace: false, 58 | }, 59 | }; 60 | } 61 | 62 | /** 63 | * Config for running tests and/or building or serving examples 64 | */ 65 | function testConfig(mode: string): UserConfig { 66 | const isE2E = mode === 'e2e'; 67 | const defaultOrigin = isE2E ? '/_embed/' : undefined; 68 | 69 | return { 70 | plugins: [ 71 | replaceOrigin(process.env.STACKBLITZ_SERVER_ORIGIN ?? defaultOrigin), 72 | { 73 | name: 'custom-server-config', 74 | configureServer, 75 | }, 76 | TSConfigPaths({ 77 | loose: true, // allow aliases in .html files 78 | projects: [`${__dirname}/tsconfig.test.json`], 79 | }), 80 | ], 81 | build: { 82 | target: 'es2020', 83 | outDir: 'temp/build', 84 | emptyOutDir: true, 85 | rollupOptions: { 86 | input: { 87 | sdk: 'src/index.ts', 88 | embed: 'test/embed/index.html', 89 | examples: 'examples/index.html', 90 | 'example-github-project': 'examples/open-embed-github-project/index.html', 91 | 'example-project-id': 'examples/open-embed-project-id/index.html', 92 | }, 93 | }, 94 | }, 95 | server: { 96 | port: isE2E ? 4001 : 4000, 97 | hmr: !isE2E, 98 | }, 99 | test: { 100 | dir: 'test', 101 | globals: false, 102 | include: ['**/unit/**/*.spec.ts'], 103 | exclude: ['**/node_modules/**'], 104 | coverage: { 105 | provider: 'c8', 106 | include: ['**/src/**/*.ts', '**/test/server/**/*.ts'], 107 | exclude: ['**/node_modules/**'], 108 | reportsDirectory: 'temp/coverage', 109 | }, 110 | }, 111 | }; 112 | } 113 | 114 | function replaceOrigin(origin?: string): Plugin { 115 | return ReplacePlugin({ 116 | preventAssignment: true, 117 | values: { 118 | __STACKBLITZ_SERVER_ORIGIN__: JSON.stringify(origin), 119 | }, 120 | }); 121 | } 122 | 123 | /** 124 | * Serve a custom HTML page for requests to '/_embed/*' 125 | */ 126 | function configureServer(server: ViteDevServer) { 127 | // Parse URL-encoded form data in POST requests 128 | server.middlewares.use(bodyParser.urlencoded({ extended: true })); 129 | 130 | server.middlewares.use('/_embed', async (req, res, next) => { 131 | const pathname = req.url && new URL(req.url, 'http://localhost/').pathname; 132 | const ext = pathname && path.parse(pathname).ext; 133 | 134 | if (ext === '' || ext === '.html') { 135 | const content = await fs.readFile(`${__dirname}/test/embed/index.html`); 136 | const html = content.toString().replace('{{PROJECT_DATA}}', getProjectDataString(req)); 137 | res.statusCode = 200; 138 | res.end(html); 139 | } else { 140 | next(); 141 | } 142 | }); 143 | 144 | server.middlewares.use('/examples', async (req, res, next) => { 145 | if (req.url.includes('?')) { 146 | const query = querystring.parse(req.url.split('?').pop()); 147 | 148 | if (query.corp === '1') { 149 | res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); 150 | res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); 151 | } 152 | } 153 | 154 | next(); 155 | }); 156 | } 157 | 158 | function getProjectDataString(req: any): string { 159 | if (req.method === 'POST' && req.body?.project) { 160 | const project = { 161 | ...req.body.project, 162 | }; 163 | 164 | if (typeof project.settings === 'string') { 165 | project.settings = JSON.parse(project.settings); 166 | } 167 | 168 | return JSON.stringify(project, null, 2); 169 | } 170 | 171 | return 'null'; 172 | } 173 | --------------------------------------------------------------------------------