Skip to content

Commit

Permalink
feat(runtime): add preview.pathname (#233)
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio authored Aug 12, 2024
1 parent bd0ca1a commit 9bf2156
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 58 deletions.
20 changes: 17 additions & 3 deletions docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,32 @@ Configure whether or not the editor should be rendered. If an object is provided
##### `previews`
Configure which ports should be used for the previews allowing you to align the behavior with your demo application's dev server setup. If not specified, the lowest port will be used.

You can optionally provide these as an array of tuples where the first element is the port number and the second is the title of the preview, or as an object.
<PropertyTable inherited type={'Preview[]'} />

The `Preview` type has the following shape:

```ts
type Preview = string
type Preview =
| number
| string
| [port: number, title: string]
| { port: number, title: string }
| [port: number, title: string, pathname: string]
| { port: number, title: string, pathname?: string }

```

Example value:

```yaml
previews:
- 3000 # Preview is on :3000/
- "3001/docs" # Preview is on :3001/docs/
- [3002, "Dev Server"] # Preview is on :3002/. Displayed title is "Dev Server".
- [3003, "Dev Server", "/docs"] # Preview is on :3003/docs/. Displayed title is "Dev Server".
- { port: 3004, title: "Dev Server" } # Preview is on :3004/. Displayed title is "Dev Server".
- { port: 3005, title: "Dev Server", pathname: "/docs" } # Preview is on :3005/docs/. Displayed title is "Dev Server".
```
##### `mainCommand`
The main command to be executed. This command will run after the `prepareCommands`.
<PropertyTable inherited type="Command" />
Expand Down
78 changes: 78 additions & 0 deletions packages/runtime/src/store/previews.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { assert, expect, test } from 'vitest';
import { PreviewsStore } from './previews.js';
import type { PortListener, WebContainer } from '@webcontainer/api';

test("preview is set ready on webcontainer's event", async () => {
const { store, emit } = await getStore();
store.setPreviews([3000]);

assert(store.previews.value);
expect(store.previews.value[0].ready).toBe(false);

emit(3000, 'open', 'https://localhost');

expect(store.previews.value![0].ready).toBe(true);
});

test('preview is not set ready when different port is ready', async () => {
const { store, emit } = await getStore();
store.setPreviews([3000]);

assert(store.previews.value);
expect(store.previews.value[0].ready).toBe(false);

emit(3001, 'open', 'https://localhost');

expect(store.previews.value[0].ready).toBe(false);
});

test('marks multiple preview infos ready', async () => {
const { store, emit } = await getStore();
store.setPreviews([
{ port: 3000, title: 'Dev' },
{ port: 3000, title: 'Docs', pathname: '/docs' },
]);

assert(store.previews.value);
expect(store.previews.value).toHaveLength(2);

expect(store.previews.value[0].ready).toBe(false);
expect(store.previews.value[0].pathname).toBe(undefined);

expect(store.previews.value[1].ready).toBe(false);
expect(store.previews.value[1].pathname).toBe('/docs');

emit(3000, 'open', 'https://localhost');

expect(store.previews.value[0].ready).toBe(true);
expect(store.previews.value[1].ready).toBe(true);
});

async function getStore() {
const listeners: PortListener[] = [];

const webcontainer: Pick<WebContainer, 'on'> = {
on: (type, listener) => {
if (type === 'port') {
listeners.push(listener as PortListener);
}

return () => undefined;
},
};

const promise = new Promise<WebContainer>((resolve) => {
resolve(webcontainer as WebContainer);
});

await promise;

return {
store: new PreviewsStore(promise),
emit: (...args: Parameters<PortListener>) => {
assert(listeners.length > 0, 'Port listeners were not captured');

listeners.forEach((cb) => cb(...args));
},
};
}
38 changes: 18 additions & 20 deletions packages/runtime/src/store/previews.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { PreviewSchema } from '@tutorialkit/types';
import type { WebContainer } from '@webcontainer/api';
import { atom } from 'nanostores';
import { PreviewInfo } from '../webcontainer/preview-info.js';
import type { WebContainer } from '@webcontainer/api';
import { PortInfo } from '../webcontainer/port-info.js';

export class PreviewsStore {
private _availablePreviews = new Map<number, PreviewInfo>();
private _availablePreviews = new Map<number, PortInfo>();
private _previewsLayout: PreviewInfo[] = [];

/**
Expand All @@ -21,18 +22,19 @@ export class PreviewsStore {
const webcontainer = await webcontainerPromise;

webcontainer.on('port', (port, type, url) => {
let previewInfo = this._availablePreviews.get(port);
let portInfo = this._availablePreviews.get(port);

if (!portInfo) {
portInfo = new PortInfo(port, url, type === 'open');

if (!previewInfo) {
previewInfo = new PreviewInfo(port, type === 'open');
this._availablePreviews.set(port, previewInfo);
this._availablePreviews.set(port, portInfo);
}

previewInfo.ready = type === 'open';
previewInfo.baseUrl = url;
portInfo.ready = type === 'open';
portInfo.origin = url;

if (this._previewsLayout.length === 0) {
this.previews.set([previewInfo]);
this.previews.set([new PreviewInfo({}, portInfo)]);
} else {
this._previewsLayout = [...this._previewsLayout];
this.previews.set(this._previewsLayout);
Expand All @@ -55,20 +57,16 @@ export class PreviewsStore {
// if the schema is `true`, we just use the default empty array
const previews = config === true ? [] : config ?? [];

const previewInfos = previews.map((preview) => {
const info = new PreviewInfo(preview);
const previewInfos = previews.map((previewConfig) => {
const preview = PreviewInfo.parse(previewConfig);
let portInfo = this._availablePreviews.get(preview.port);

let previewInfo = this._availablePreviews.get(info.port);

if (!previewInfo) {
previewInfo = info;

this._availablePreviews.set(previewInfo.port, previewInfo);
} else {
previewInfo.title = info.title;
if (!portInfo) {
portInfo = new PortInfo(preview.port);
this._availablePreviews.set(preview.port, portInfo);
}

return previewInfo;
return new PreviewInfo(preview, portInfo);
});

let areDifferent = previewInfos.length != this._previewsLayout.length;
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime/src/webcontainer/port-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class PortInfo {
constructor(
readonly port: number,
public origin?: string,
public ready: boolean = false,
) {}
}
78 changes: 59 additions & 19 deletions packages/runtime/src/webcontainer/preview-info.spec.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,120 @@
import { describe, it, expect } from 'vitest';
import { PreviewInfo } from './preview-info.js';
import { PortInfo } from './port-info.js';

describe('PreviewInfo', () => {
it('should accept a port', () => {
const previewInfo = new PreviewInfo(3000);
it('should accept a number for port', () => {
const previewInfo = PreviewInfo.parse(3000);

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe(undefined);
expect(previewInfo.pathname).toBe(undefined);
});

it('should accept a string for port and pathname', () => {
const previewInfo = PreviewInfo.parse('3000/some/nested/path');

expect(previewInfo.port).toBe(3000);
expect(previewInfo.pathname).toBe('some/nested/path');
expect(previewInfo.title).toBe(undefined);
});

it('should accept a tuple of [port, title]', () => {
const previewInfo = new PreviewInfo([3000, 'Local server']);
const previewInfo = PreviewInfo.parse([3000, 'Local server']);

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe(undefined);
});

it('should accept a tuple of [port, title, pathname]', () => {
const previewInfo = PreviewInfo.parse([3000, 'Local server', '/docs']);

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe('/docs');
});

it('should accept an object with { port, title }', () => {
const previewInfo = new PreviewInfo({ port: 3000, title: 'Local server' });
const previewInfo = PreviewInfo.parse({ port: 3000, title: 'Local server' });

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe(undefined);
});

it('should accept an object with { port, title, pathname }', () => {
const previewInfo = PreviewInfo.parse({ port: 3000, title: 'Local server', pathname: '/docs' });

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe('/docs');
});

it('should not be ready by default', () => {
const previewInfo = new PreviewInfo(3000);
const previewInfo = new PreviewInfo({}, new PortInfo(3000));

expect(previewInfo.ready).toBe(false);
});

it('should be ready if explicitly set', () => {
const previewInfo = new PreviewInfo(3000, true);
const previewInfo = new PreviewInfo({}, new PortInfo(3000, undefined, true));

expect(previewInfo.ready).toBe(true);
});

it('should not be ready if explicitly set', () => {
const previewInfo = new PreviewInfo(3000, false);
const previewInfo = new PreviewInfo({}, new PortInfo(3000, undefined, false));

expect(previewInfo.ready).toBe(false);
});

it('should have a url with a custom pathname and baseUrl', () => {
const previewInfo = new PreviewInfo(3000);
previewInfo.baseUrl = 'https://example.com';
previewInfo.pathname = '/foo';
const parsed = PreviewInfo.parse('3000/foo');
const previewInfo = new PreviewInfo(parsed, new PortInfo(parsed.port));
previewInfo.portInfo.origin = 'https://example.com';

expect(previewInfo.url).toBe('https://example.com/foo');
});

it('should be equal to another preview info with the same port and title', () => {
const a = new PreviewInfo(3000);
const b = new PreviewInfo(3000);
const a = new PreviewInfo({}, new PortInfo(3000));
const b = new PreviewInfo({}, new PortInfo(3000));

expect(PreviewInfo.equals(a, b)).toBe(true);
});

it('should not be equal to another preview info with a different port', () => {
const a = new PreviewInfo(3000);
const b = new PreviewInfo(4000);
const a = new PreviewInfo({}, new PortInfo(3000));
const b = new PreviewInfo({}, new PortInfo(4000));

expect(PreviewInfo.equals(a, b)).toBe(false);
});

it('should not be equal to another preview info with a different title', () => {
const a = new PreviewInfo([3000, 'Local server']);
const b = new PreviewInfo([3000, 'Remote server']);
const parsed = {
a: PreviewInfo.parse([3000, 'Local server']),
b: PreviewInfo.parse([3000, 'Remote server']),
};

const a = new PreviewInfo(parsed.a, new PortInfo(parsed.a.port));
const b = new PreviewInfo(parsed.b, new PortInfo(parsed.b.port));

expect(PreviewInfo.equals(a, b)).toBe(false);
});

it('should not be equal to another preview info with a different pathname', () => {
const a = new PreviewInfo(3000);
const b = new PreviewInfo(3000);
const parsed = {
a: PreviewInfo.parse(3000),
b: PreviewInfo.parse('3000/b'),
c: PreviewInfo.parse('3000/c'),
};

a.pathname = '/foo';
const a = new PreviewInfo(parsed.a, new PortInfo(parsed.a.port));
const b = new PreviewInfo(parsed.b, new PortInfo(parsed.b.port));
const c = new PreviewInfo(parsed.c, new PortInfo(parsed.c.port));

expect(PreviewInfo.equals(a, b)).toBe(false);
expect(PreviewInfo.equals(b, c)).toBe(false);
});
});
Loading

0 comments on commit 9bf2156

Please sign in to comment.