├── .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 |
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 |
--------------------------------------------------------------------------------