-
-
Notifications
You must be signed in to change notification settings - Fork 63
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
base: main
Are you sure you want to change the base?
Changes from 8 commits
86bbe47
f3f8eda
435c146
87ec5ef
51ab136
dcc19ef
c15a56d
8cea804
b413863
c740604
ba42726
6f488ca
6ae6ed3
689a9c7
10297c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
'@lagon/dashboard': patch | ||
'@lagon/ui': patch | ||
--- | ||
|
||
feat: add playground typescript |
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({ | ||
QuiiBz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
entryPoints: [`${PROJECT_ROOT}index.ts`], | ||
outdir: '/dist', | ||
format: 'esm', | ||
write: false, | ||
bundle: true, | ||
target: 'esnext', | ||
platform: 'browser', | ||
conditions: ['lagon', 'worker'], | ||
plugins: [resolvePlugin(files)], | ||
QuiiBz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}), | ||
[isEsbuildLoading, esbuildStatus], | ||
); | ||
|
||
useEffect(() => { | ||
loadEsbuild(); | ||
}, []); | ||
|
||
return { isEsbuildLoading, esbuildStatus, build }; | ||
}; | ||
|
||
export default useEsbuild; |
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 { | ||
|
@@ -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(() => { | ||
|
@@ -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!, { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Completely agree. We could probably add a new column to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Give it a try and see if it meets your requirements. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
@@ -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> | ||
|
@@ -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> | ||
|
Uh oh!
There was an error while loading. Please reload this page.