Skip to content
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

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

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions crates/cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct CreateFunctionRequest {
domains: Vec<String>,
env: Vec<String>,
cron: Option<String>,
platform: String,
}

#[derive(Deserialize, Debug)]
Expand Down Expand Up @@ -130,6 +131,7 @@ pub async fn deploy(
domains: Vec::new(),
env: Vec::new(),
cron: None,
platform: "CLI".into(),
},
)
.await?;
Expand Down
15 changes: 15 additions & 0 deletions packages/dashboard/lib/api/deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,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!")
}`;
124 changes: 124 additions & 0 deletions packages/dashboard/lib/hooks/useEsbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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: `https://esm.sh/esbuild-wasm@0.18.7/esbuild.wasm`,
});
}

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

Check warning on line 97 in packages/dashboard/lib/hooks/useEsbuild.ts

View workflow job for this annotation

GitHub Actions / Lint

React Hook useCallback has unnecessary dependencies: 'esbuildStatus' and 'isEsbuildLoading'. Either exclude them or remove the dependency array

// these options should match the ones in crates/cli/src/utils/deployments.rs
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'],
define: { 'process.env.NODE_ENV': 'production' },
plugins: [resolvePlugin(files)],
QuiiBz marked this conversation as resolved.
Show resolved Hide resolved
}),
[isEsbuildLoading, esbuildStatus],

Check warning on line 114 in packages/dashboard/lib/hooks/useEsbuild.ts

View workflow job for this annotation

GitHub Actions / Lint

React Hook useCallback has unnecessary dependencies: 'esbuildStatus' and 'isEsbuildLoading'. Either exclude them or remove the dependency array
);

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

Check warning on line 119 in packages/dashboard/lib/hooks/useEsbuild.ts

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'loadEsbuild'. Either include it or remove the dependency array

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
3 changes: 3 additions & 0 deletions packages/dashboard/lib/trpc/functionsRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const functionsRouter = (t: T) =>
createdAt: 'desc',
},
},
platform: true,
},
});

Expand Down Expand Up @@ -197,6 +198,7 @@ LIMIT 100`,
.array()
.max(ENVIRONMENT_VARIABLES_PER_FUNCTION),
cron: z.string().nullable(),
platform: z.enum(['CLI', 'Playground'] as const).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
Expand Down Expand Up @@ -258,6 +260,7 @@ LIMIT 100`,
},
},
cron: input.cron,
platform: input.platform ?? 'CLI',
},
select: {
id: true,
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 @@ -154,6 +154,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 @@ -168,6 +168,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
14 changes: 9 additions & 5 deletions packages/dashboard/pages/functions/[functionId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ const Function = () => {
) : null}
<Nav defaultValue="overview">
<Nav.List
rightItem={
<Button href={`/playground/${func?.id}`} leftIcon={<PlayIcon className="h-4 w-4" />}>
{t('playground')}
</Button>
}
{...(func?.platform === 'Playground'
? {
rightItem: (
<Button href={`/playground/${func?.id}`} leftIcon={<PlayIcon className="h-4 w-4" />}>
{t('playground')}
</Button>
),
}
: {})}
>
<Nav.Link value="overview">{t('overview')}</Nav.Link>
<Nav.Link value="deployments">{t('deployments')}</Nav.Link>
Expand Down
27 changes: 19 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 All @@ -30,21 +30,32 @@ const Home = () => {
domains: [],
env: [],
cron: null,
platform: 'Playground',
});

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 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
Loading
Loading