Skip to content

Commit

Permalink
Adds mechanism to support managed npm install runs from within the ap…
Browse files Browse the repository at this point in the history
…p builder (#381)

* feat: add initial app specific PackageJsonContext, and small test interface in settings to exercise it

* fix: make borders more consistent in the statusbar errors panel

* fix: address incorrect useEffect dependencies

* fix: remove forgotten trigger button from preview!

* fix: address missing grow-0 shrink-0 on statusbar, it was getting squished in some cases

* fix: remove bogus processMetadata.set line, this was an oversight

* fix: remove unused variable
  • Loading branch information
1egoman authored Oct 18, 2024
1 parent f72160f commit 3948ca0
Show file tree
Hide file tree
Showing 11 changed files with 201 additions and 19 deletions.
39 changes: 38 additions & 1 deletion packages/api/server/channels/app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
FileUpdatedPayloadType,
PreviewStartPayloadType,
PreviewStopPayloadType,
DependenciesInstallPayloadType,
DependenciesInstallPayloadSchema,
} from '@srcbook/shared';

import WebSocketServer, {
Expand All @@ -16,7 +18,7 @@ import WebSocketServer, {
} from '../ws-client.mjs';
import { loadApp } from '../../apps/app.mjs';
import { fileUpdated, pathToApp } from '../../apps/disk.mjs';
import { vite } from '../../exec.mjs';
import { vite, npmInstall } from '../../exec.mjs';

const VITE_PORT_REGEX = /Local:.*http:\/\/localhost:([0-9]{1,4})/;

Expand Down Expand Up @@ -135,6 +137,40 @@ async function previewStop(
});
}

async function dependenciesInstall(
payload: DependenciesInstallPayloadType,
context: AppContextType,
conn: ConnectionContextType,
) {
const app = await loadApp(context.params.appId);

if (!app) {
return;
}

npmInstall({
args: [],
cwd: pathToApp(app.externalId),
packages: payload.packages ?? undefined,
stdout: (data) => {
conn.reply(`app:${app.externalId}`, 'dependencies:install:log', {
log: { type: 'stdout', data: data.toString('utf8') },
});
},
stderr: (data) => {
conn.reply(`app:${app.externalId}`, 'dependencies:install:log', {
log: { type: 'stderr', data: data.toString('utf8') },
});
},
onExit: (code) => {
conn.reply(`app:${app.externalId}`, 'dependencies:install:status', {
status: code === 0 ? 'complete' : 'failed',
code,
});
},
});
}

async function onFileUpdated(payload: FileUpdatedPayloadType, context: AppContextType) {
const app = await loadApp(context.params.appId);

Expand All @@ -150,6 +186,7 @@ export function register(wss: WebSocketServer) {
.channel('app:<appId>')
.on('preview:start', PreviewStartPayloadSchema, previewStart)
.on('preview:stop', PreviewStopPayloadSchema, previewStop)
.on('dependencies:install', DependenciesInstallPayloadSchema, dependenciesInstall)
.on('file:updated', FileUpdatedPayloadSchema, onFileUpdated)
.onJoin(async (topic, ws) => {
const app = await loadApp(topic.split(':')[1]!);
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/schemas/websockets.mts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,19 @@ export const PreviewStatusPayloadSchema = z.union([

export const PreviewStartPayloadSchema = z.object({});
export const PreviewStopPayloadSchema = z.object({});

export const DependenciesInstallPayloadSchema = z.object({
packages: z.array(z.string()).nullish(),
});

export const DependenciesInstallLogPayloadSchema = z.object({
log: z.union([
z.object({ type: z.literal('stdout'), data: z.string() }),
z.object({ type: z.literal('stderr'), data: z.string() }),
]),
});

export const DependenciesInstallStatusPayloadSchema = z.object({
status: z.enum(['complete', 'failed']),
code: z.number().int(),
});
8 changes: 8 additions & 0 deletions packages/shared/src/types/websockets.mts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import {
PreviewStatusPayloadSchema,
PreviewStopPayloadSchema,
PreviewStartPayloadSchema,
DependenciesInstallPayloadSchema,
DependenciesInstallLogPayloadSchema,
DependenciesInstallStatusPayloadSchema,
} from '../schemas/websockets.mjs';

export type CellExecPayloadType = z.infer<typeof CellExecPayloadSchema>;
Expand Down Expand Up @@ -101,3 +104,8 @@ export type FileDeletedPayloadType = z.infer<typeof FileDeletedPayloadSchema>;
export type PreviewStatusPayloadType = z.infer<typeof PreviewStatusPayloadSchema>;
export type PreviewStartPayloadType = z.infer<typeof PreviewStartPayloadSchema>;
export type PreviewStopPayloadType = z.infer<typeof PreviewStopPayloadSchema>;
export type DependenciesInstallPayloadType = z.infer<typeof DependenciesInstallPayloadSchema>;
export type DependenciesInstallLogPayloadType = z.infer<typeof DependenciesInstallLogPayloadSchema>;
export type DependenciesInstallStatusPayloadType = z.infer<
typeof DependenciesInstallStatusPayloadSchema
>;
6 changes: 6 additions & 0 deletions packages/web/src/clients/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import {
PreviewStatusPayloadSchema,
PreviewStartPayloadSchema,
PreviewStopPayloadSchema,
DependenciesInstallPayloadSchema,
DependenciesInstallLogPayloadSchema,
DependenciesInstallStatusPayloadSchema,
} from '@srcbook/shared';
import Channel from '@/clients/websocket/channel';
import WebSocketClient from '@/clients/websocket/client';
Expand Down Expand Up @@ -94,6 +97,8 @@ export class SessionChannel extends Channel<
const IncomingAppEvents = {
file: FilePayloadSchema,
'preview:status': PreviewStatusPayloadSchema,
'dependencies:install:log': DependenciesInstallLogPayloadSchema,
'dependencies:install:status': DependenciesInstallStatusPayloadSchema,
};

const OutgoingAppEvents = {
Expand All @@ -103,6 +108,7 @@ const OutgoingAppEvents = {
'file:deleted': FileDeletedPayloadSchema,
'preview:start': PreviewStartPayloadSchema,
'preview:stop': PreviewStopPayloadSchema,
'dependencies:install': DependenciesInstallPayloadSchema,
};

export class AppChannel extends Channel<typeof IncomingAppEvents, typeof OutgoingAppEvents> {
Expand Down
36 changes: 35 additions & 1 deletion packages/web/src/components/apps/panels/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
import { Button } from '@srcbook/components/src/components/ui/button';
import { usePackageJson } from '../use-package-json';

export default function SettingsPanel() {
return null;
const { status, output, npmInstall } = usePackageJson();

return (
<div className="flex flex-col gap-4 px-5 w-[360px]">
<div>
<Button onClick={() => npmInstall()} disabled={status === 'installing'}>
Run npm install
</Button>
</div>
<div>
<Button
onClick={() => npmInstall(['uuid'])}
variant="secondary"
disabled={status === 'installing'}
>
Run npm install uuid
</Button>
</div>

{status !== 'idle' ? (
<>
<span>
Status: <code>{status}</code>
</span>
<pre className="font-mono text-sm bg-tertiary p-2 overflow-auto rounded-md border">
{/* FIXME: disambiguate between stdout and stderr in here using n.type! */}
{output.map((n) => n.data).join('\n')}
</pre>
</>
) : null}
</div>
);
}
2 changes: 1 addition & 1 deletion packages/web/src/components/apps/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function Panel(props: {
<ChevronsLeftIcon size={14} />
</button>
</div>
<div className="w-[200px] border-l pr-1.5 flex-1 overflow-auto">{props.children}</div>
<div className="min-w-[200px] border-l pr-1.5 flex-1 overflow-auto">{props.children}</div>
</div>
);
}
11 changes: 6 additions & 5 deletions packages/web/src/components/apps/statusbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,19 @@ function CollapsibleErrorMessage({ error }: CollapsibleErrorMessageProps) {
<>
<button
className={cn(
'flex items-center gap-2 font-mono p-2 border-b first:border-b-0 focus-visible:outline-none ring-inset focus-visible:ring-1 focus-visible:ring-ring',
'flex items-center gap-2 font-mono p-2 border-b focus-visible:outline-none',
'ring-inset focus-visible:ring-1 focus-visible:ring-ring',
{
'border-b': !open,
'border-b-0': open,
},
)}
onClick={() => setOpen((n) => !n)}
>
{open ? <ChevronDownIcon size={16} /> : <ChevronRightIcon size={16} />}
<span className="text-tertiary-foreground select-none pointer-events-none">
<span className="text-tertiary-foreground select-none pointer-events-none whitespace-nowrap">
{error.timestamp.toISOString()}{' '}
</span>
{getLabelForError(error)}
<span className="whitespace-nowrap">{getLabelForError(error)}</span>
</button>
{open ? (
<pre className={cn('text-sm p-2', { 'ml-[15px] pl-4 mb-4 border-l': open })}>
Expand All @@ -56,7 +57,7 @@ export default function Statusbar() {

return (
<>
<div className="flex items-center justify-between h-8 border-t border-b px-2 w-full">
<div className="grow-0 shrink-0 flex items-center justify-between h-8 border-t border-b px-2 w-full">
<Button
size="sm"
variant={open ? 'default' : 'icon'}
Expand Down
8 changes: 4 additions & 4 deletions packages/web/src/components/apps/use-logs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';

import { AppChannel } from '@/clients/websocket';
import { PreviewStatusPayloadType } from '@srcbook/shared';
Expand Down Expand Up @@ -37,10 +37,10 @@ export function LogsProvider({ channel, children }: ProviderPropsType) {
setUnreadLogsCount(0);
}

function addError(error: Omit<LogMessage, 'timestamp'>) {
const addError = useCallback((error: Omit<LogMessage, 'timestamp'>) => {
setLogs((logs) => [{ ...error, timestamp: new Date() }, ...logs]);
setUnreadLogsCount((n) => n + 1);
}
}, []);

function togglePane() {
setOpen((n) => !n);
Expand All @@ -59,7 +59,7 @@ export function LogsProvider({ channel, children }: ProviderPropsType) {
channel.on('preview:status', onViteError);

return () => channel.off('preview:status', onViteError);
}, [channel]);
}, [channel, addError]);

// TODO: if npm install fails, add an error log

Expand Down
82 changes: 82 additions & 0 deletions packages/web/src/components/apps/use-package-json.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { createContext, useCallback, useContext, useState } from 'react';
import { OutputType } from '@srcbook/components/src/types';
import { AppChannel } from '@/clients/websocket';
import {
DependenciesInstallLogPayloadType,
DependenciesInstallStatusPayloadType,
} from '@srcbook/shared';
import { useLogs } from './use-logs';

type NpmInstallStatus = 'idle' | 'installing' | 'complete' | 'failed';

export interface PackageJsonContextValue {
npmInstall: (packages?: string[]) => void;
status: NpmInstallStatus;
installing: boolean;
failed: boolean;
output: Array<OutputType>;
}

const PackageJsonContext = createContext<PackageJsonContextValue | undefined>(undefined);

type ProviderPropsType = {
channel: AppChannel;
children: React.ReactNode;
};

export function PackageJsonProvider({ channel, children }: ProviderPropsType) {
const [status, setStatus] = useState<NpmInstallStatus>('idle');
const [output, setOutput] = useState<Array<OutputType>>([]);

const { addError } = useLogs();

const npmInstall = useCallback(
(packages?: Array<string>) => {
// NOTE: caching of the log output is required here because socket events that call callback
// functions in here hold on to old scope values
let contents = '';

const logCallback = ({ log }: DependenciesInstallLogPayloadType) => {
setOutput((old) => [...old, log]);
contents += log.data;
};
channel.on('dependencies:install:log', logCallback);

const statusCallback = ({ status }: DependenciesInstallStatusPayloadType) => {
channel.off('dependencies:install:log', logCallback);
channel.off('dependencies:install:status', statusCallback);
setStatus(status);

if (status === 'failed') {
addError({ type: 'npm_install_error', contents });
}
};
channel.on('dependencies:install:status', statusCallback);

setOutput([]);
setStatus('installing');
channel.push('dependencies:install', { packages });
},
[channel, addError],
);

const context: PackageJsonContextValue = {
npmInstall,
status,
installing: status === 'installing',
failed: status === 'failed',
output,
};

return <PackageJsonContext.Provider value={context}>{children}</PackageJsonContext.Provider>;
}

export function usePackageJson() {
const context = useContext(PackageJsonContext);

if (!context) {
throw new Error('usePackageJson must be used within a PackageJsonProvider');
}

return context;
}
7 changes: 1 addition & 6 deletions packages/web/src/components/apps/workspace/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type PropsType = {

export function Preview(props: PropsType) {
const { url, status, start, lastStoppedError } = usePreview();
const { addError, togglePane } = useLogs();
const { togglePane } = useLogs();

const isActive = props.isActive ?? true;

Expand Down Expand Up @@ -41,11 +41,6 @@ export function Preview(props: PropsType) {

return (
<div className={cn('w-full h-full', props.className)}>
<div className="absolute">
<button onClick={() => addError({ type: 'vite_error', contents: 'Bogus error' })}>
Trigger
</button>
</div>
<iframe className="w-full h-full" src={url} title="App preview" />
</div>
);
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/routes/apps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FilesProvider } from '@/components/apps/use-files';
import { Editor } from '@/components/apps/workspace/editor/editor';
import { PreviewProvider } from '@/components/apps/use-preview';
import { LogsProvider } from '@/components/apps/use-logs';
import { PackageJsonProvider } from '@/components/apps/use-package-json';
import { ChatPanel } from '@/components/chat';
import DiffModal from '@/components/apps/diff-modal';
import { FileDiffType } from '@srcbook/shared';
Expand Down Expand Up @@ -61,7 +62,9 @@ export function AppsPage() {
>
<PreviewProvider channel={channelRef.current}>
<LogsProvider channel={channelRef.current}>
<Apps app={app} />
<PackageJsonProvider channel={channelRef.current}>
<Apps app={app} />
</PackageJsonProvider>
</LogsProvider>
</PreviewProvider>
</FilesProvider>
Expand Down

0 comments on commit 3948ca0

Please sign in to comment.