Skip to content

feat(dashboard, ui): Support TypeScript in the playground #919

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/orange-items-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@lagon/dashboard': patch
'@lagon/ui': patch
---

feat: add playground typescript
15 changes: 15 additions & 0 deletions packages/dashboard/lib/api/deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,21 @@ async function streamToString(stream: Readable): Promise<string> {
}

export async function getDeploymentCode(deploymentId: string) {
try {
const tsContent = await s3.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: `${deploymentId}.ts`,
}),
);

if (tsContent.Body instanceof Readable) {
return streamToString(tsContent.Body);
}
} catch (e) {
console.warn(`${deploymentId} haven't ts file, e: ${(e as Error).message}`);
}

const content = await s3.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Expand Down
4 changes: 4 additions & 0 deletions packages/dashboard/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ export type Regions = keyof typeof REGIONS;
export const DEFAULT_FUNCTION = `export function handler(request) {
return new Response("Hello World!")
}`;

export const DEFAULT_TS_FUNCTION = `export function handler(request: Request) {
return new Response("Hello World!")
}`;
122 changes: 122 additions & 0 deletions packages/dashboard/lib/hooks/useEsbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as esbuild from 'esbuild-wasm';
import { Plugin, Loader } from 'esbuild-wasm';
import { useCallback, useEffect, useState } from 'react';

type EsbuildFileSystem = Map<
string,
{
content: string;
}
>;

const PROJECT_ROOT = '/project/';

const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.css', '.json'];

const extname = (path: string): string => {
const m = /(\.[a-zA-Z0-9]+)$/.exec(path);
return m ? m[1] : '';
};

const inferLoader = (p: string): Loader => {
const ext = extname(p);
if (RESOLVE_EXTENSIONS.includes(ext)) {
return ext.slice(1) as Loader;
}
if (ext === '.mjs' || ext === '.cjs') {
return 'js';
}
return 'text';
};

const resolvePlugin = (files: EsbuildFileSystem): Plugin => {
return {
name: 'resolve',
setup(build) {
build.onResolve({ filter: /.*/ }, async args => {
if (args.path.startsWith(PROJECT_ROOT)) {
return {
path: args.path,
};
}
});

build.onLoad({ filter: /.*/ }, args => {
if (args.path.startsWith(PROJECT_ROOT)) {
const name = args.path.replace(PROJECT_ROOT, '');
const file = files.get(name);
if (file) {
return {
contents: file.content,
loader: inferLoader(args.path),
};
}
}
});
},
};
};

export enum ESBuildStatus {
Success,
Fail,
Loading,
}

class EsBuildSingleton {
static isFirst = true;
static getIsFirst = () => {
if (EsBuildSingleton.isFirst) {
EsBuildSingleton.isFirst = false;
return true;
}
return EsBuildSingleton.isFirst;
};
}

export const useEsbuild = () => {
const [esbuildStatus, setEsbuildStatus] = useState(ESBuildStatus.Loading);
const [isEsbuildLoading, setIsEsbuildLoading] = useState(true);

// React.StrictMode will cause useEffect to run twice
const loadEsbuild = useCallback(async () => {
try {
if (EsBuildSingleton.getIsFirst()) {
await esbuild.initialize({
wasmURL: `/esbuild.wasm`,
});
}

setEsbuildStatus(ESBuildStatus.Success);
} catch (e) {
setEsbuildStatus(ESBuildStatus.Fail);
console.error(e);
} finally {
setIsEsbuildLoading(false);
}
}, [isEsbuildLoading, esbuildStatus]);

const build = useCallback(
(files: EsbuildFileSystem) =>
esbuild.build({
entryPoints: [`${PROJECT_ROOT}index.ts`],
outdir: '/dist',
format: 'esm',
write: false,
bundle: true,
target: 'esnext',
platform: 'browser',
conditions: ['lagon', 'worker'],
plugins: [resolvePlugin(files)],
}),
[isEsbuildLoading, esbuildStatus],
);

useEffect(() => {
loadEsbuild();
}, []);

return { isEsbuildLoading, esbuildStatus, build };
};

export default useEsbuild;
7 changes: 7 additions & 0 deletions packages/dashboard/lib/trpc/deploymentsRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const deploymentsRouter = (t: T) =>
z.object({
functionId: z.string(),
functionSize: z.number(),
tsSize: z.number(),
assets: z
.object({
name: z.string(),
Expand Down Expand Up @@ -67,6 +68,11 @@ export const deploymentsRouter = (t: T) =>
};

const codeUrl = await getPresignedUrl(`${deployment.id}.js`, input.functionSize);

let tsCodeUrl: string | undefined;
if (input.tsSize > 0) {
tsCodeUrl = await getPresignedUrl(`${deployment.id}.ts`, input.tsSize);
}
const assetsUrls: Record<string, string> = {};

await Promise.all(
Expand All @@ -80,6 +86,7 @@ export const deploymentsRouter = (t: T) =>
deploymentId: deployment.id,
codeUrl,
assetsUrls,
tsCodeUrl,
};
}),
deploymentDeploy: t.procedure
Expand Down
2 changes: 2 additions & 0 deletions packages/dashboard/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export default {
'playground.deploy.success': 'Function deployed successfully.',
'playground.deploy.error': 'Failed to deploy Function.',
'playground.reload': 'Reload',
'playground.esbuild.error': `Since your browser doesn't support wasm, you can't use typescript.`,
'playground.esbuild.loading': 'Initializing esbuild',

'function.nav.playground': 'Playground',
'function.nav.overview': 'Overview',
Expand Down
2 changes: 2 additions & 0 deletions packages/dashboard/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export default defineLocale({
'function.nav.logs': 'Logs',
'function.nav.settings': 'Paramètres',
'function.nav.cron': 'Cron',
'playground.esbuild.error': `Étant donné que votre navigateur ne prend pas en charge wasm, vous ne pouvez pas utiliser le typescript.`,
'playground.esbuild.loading': `esbuild est en cours d'initialisation`,

'functions.overview.usage': 'Utilisation & Limites',
'functions.overview.usage.requests': 'Requêtes',
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@trpc/react-query": "^10.18.0",
"@trpc/server": "^10.18.0",
"clickhouse": "^2.6.0",
"esbuild-wasm": "0.17.19",
"cron-parser": "^4.8.1",
"cronstrue": "^2.27.0",
"final-form": "^4.20.7",
Expand Down
26 changes: 18 additions & 8 deletions packages/dashboard/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { trpc } from 'lib/trpc';
import { useRouter } from 'next/router';
import { getLocaleProps, useScopedI18n } from 'locales';
import { GetStaticProps } from 'next';
import { DEFAULT_FUNCTION } from 'lib/constants';
import { DEFAULT_FUNCTION, DEFAULT_TS_FUNCTION } from 'lib/constants';

const Home = () => {
const createFunction = trpc.functionCreate.useMutation();
Expand Down Expand Up @@ -35,16 +35,26 @@ const Home = () => {
const deployment = await createDeployment.mutateAsync({
functionId: func.id,
functionSize: new TextEncoder().encode(DEFAULT_FUNCTION).length,
tsSize: new TextEncoder().encode(DEFAULT_TS_FUNCTION).length,
assets: [],
});

await fetch(deployment.codeUrl, {
method: 'PUT',
headers: {
'Content-Type': 'text/javascript',
},
body: DEFAULT_FUNCTION,
});
await await Promise.all([
fetch(deployment.codeUrl, {
method: 'PUT',
headers: {
'Content-Type': 'text/javascript',
},
body: DEFAULT_FUNCTION,
}),
fetch(deployment.tsCodeUrl!, {
method: 'PUT',
headers: {
'Content-Type': 'text/javascript',
},
body: DEFAULT_TS_FUNCTION,
}),
]);

await deployDeployment.mutateAsync({
functionId: func.id,
Expand Down
84 changes: 73 additions & 11 deletions packages/dashboard/pages/playground/[functionId].tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import { useMonaco } from '@monaco-editor/react';
import { useRouter } from 'next/router';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import FunctionLinks from 'lib/components/FunctionLinks';
import Playground from 'lib/components/Playground';
import useFunction from 'lib/hooks/useFunction';
import { getFullCurrentDomain } from 'lib/utils';
import { Text, Button, Form } from '@lagon/ui';
import { Text, Button, Form, LogLine } from '@lagon/ui';
import { PlayIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import useFunctionCode from 'lib/hooks/useFunctionCode';
import { useScopedI18n } from 'locales';
import { trpc } from 'lib/trpc';
import useEsbuild, { ESBuildStatus } from 'lib/hooks/useEsbuild';

const EsBuildTip: React.FC<{ esbuildStatus: ESBuildStatus }> = ({ esbuildStatus }) => {
const t = useScopedI18n('playground');
return [ESBuildStatus.Fail, ESBuildStatus.Loading].includes(esbuildStatus) ? (
<div className=" w-full">
<LogLine
level={esbuildStatus === ESBuildStatus.Loading ? 'warn' : 'error'}
message={esbuildStatus === ESBuildStatus.Loading ? t('esbuild.loading') : t('esbuild.error')}
hiddenCopy
/>
</div>
) : (
<></>
);
};

const PlaygroundPage = () => {
const {
Expand All @@ -23,6 +39,7 @@ const PlaygroundPage = () => {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const monaco = useMonaco();
const t = useScopedI18n('playground');
const { isEsbuildLoading, esbuildStatus, build } = useEsbuild();
const [isLoading, setIsLoading] = useState(false);

const reloadIframe = useCallback(() => {
Expand All @@ -46,21 +63,60 @@ const PlaygroundPage = () => {
code = monaco.editor.getModels()[1].getValue();
}

let tsCode = '';

setIsLoading(true);

if (esbuildStatus === ESBuildStatus.Success) {
try {
tsCode = code;
const files = new Map<string, { content: string }>();

files.set('index.ts', {
content: code,
});

const esbuildResult = await build(files);

if (esbuildResult.outputFiles?.[0]) {
code = esbuildResult.outputFiles[0].text;
} else {
esbuildResult.errors.forEach(e => {
console.error(e);
});
}
} catch (e) {
console.error(e);
}
}

const deployment = await createDeployment.mutateAsync({
functionId: func.id,
functionSize: new TextEncoder().encode(code).length,
tsSize: new TextEncoder().encode(tsCode).length,
assets: [],
});

await fetch(deployment.codeUrl, {
method: 'PUT',
headers: {
'Content-Type': 'text/javascript',
},
body: code,
});
await Promise.all([
fetch(deployment.codeUrl, {
method: 'PUT',
headers: {
'Content-Type': 'text/javascript',
},
body: code,
}),
...(tsCode.length > 0
? [
fetch(deployment.tsCodeUrl!, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if it makes sense to only upload the TS code when we previously had a TS code? Meaning TS is only supported when creating Functions via the dashboard, and not when creating them via the CLI (which makes sense because we can't support this)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand what you're saying. Firstly, V8 can only run JS code, which means we only have two options: one is to store only TS code and transpile it to JS at runtime, but it will reduce our performance, so I don't recommend this approach. The other option is to store both TS and JS code, but it will cost us more space. I chose the latter because for users, response time is the most important factor. Also, as you mentioned, the CLI results will only support JS, and I think this is unavoidable in the current scenario. If you want to avoid this problem, the playground would have to implement a file system (such as using a webcontainer), which would involve a lot of work and is not within the scope of this PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agree. We could probably add a new column to the Function table that will be used to check if the function has been created using the CLI or the Dashboard. With that, we could easily only show the Playground button if the Function has been created from the Dashboard.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Give it a try and see if it meets your requirements.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'll take a look this weekend and we then should be ready to merge

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@QuiiBz How is the progress, can this pr be merged?

method: 'PUT',
headers: {
'Content-Type': 'text/javascript',
},
body: tsCode,
}),
]
: []),
]);

await deployDeployment.mutateAsync({
functionId: func.id,
Expand All @@ -87,7 +143,12 @@ const PlaygroundPage = () => {
</Text>
<div className="flex items-center gap-2">
<Button href={`/functions/${func?.id}`}>{t('back')}</Button>
<Button variant="primary" leftIcon={<PlayIcon className="h-4 w-4" />} submit disabled={isLoading}>
<Button
variant="primary"
leftIcon={<PlayIcon className="h-4 w-4" />}
submit
disabled={isLoading || isEsbuildLoading}
>
{t('deploy')}
</Button>
</div>
Expand All @@ -102,7 +163,8 @@ const PlaygroundPage = () => {
</div>
<div className="flex w-screen" style={{ height: 'calc(100vh - 4rem - 3rem)' }}>
<Playground defaultValue={functionCode?.code || ''} width="50vw" height="100%" />
<div className="w-[50vw] border-l border-l-stone-200 dark:border-b-stone-700">
<div className="flex w-[50vw] flex-col border-l border-l-stone-200 dark:border-b-stone-700">
<EsBuildTip esbuildStatus={esbuildStatus} />
{func ? <iframe ref={iframeRef} className="h-full w-full" src={getFullCurrentDomain(func)} /> : null}
</div>
</div>
Expand Down
Binary file added packages/dashboard/public/esbuild.wasm
Binary file not shown.
Loading