Extensions
Extensions are one of the most powerful and valuable features of sidekick. They allow you to build custom devtools for your team that support your unique developer workflows and your codebase. Extensions in sidekick are primarily React apps, where the scaffolding for loading the app into the browser is handled by sidekick. Sidekick will also handle a convenience features for you such as interacting with the user's configuration for your extension, accessing system-level information through Node.js, and more.
To jump ahead to code, you can checkout the repository karimsa/sidekick-extension-example (opens in a new tab).
Writing a simple extension
To start writing an extension, create a .sidekick.tsx
file anywhere in your repo. Next, configure sidekick to load this extension through the sidekick.config.ts
file. Your extension file must export a React component called Page
that will be rendered in the browser. All extensions run in their own <iframe>
within sidekick, so your extension can use any React version you want and can modify global state without interfering with sidekick or other extensions.
Here is an example of a simple hello world extension written in hello.sidekick.tsx
:
import React from 'react';
export function Page() {
return <p>Hello world! Welcome to my first sidekick extension.</p>;
}
To load this extension, add the following to your sidekick.config.ts
file:
import type { SidekickConfig } from '@karimsa/sidekick/config';
export const config: SidekickConfig = {
defaultConfig: {
environments: {},
extensions: {}
},
extensions: [
{
id: 'hello-world',
icon: 'history',
name: 'Hello World',
entryPoint: './hello.sidekick.tsx'
},
]
};
Once you have done both of these, you can run sidekick via yarn sidekick start
and head over to the browser to see:
Writing backend queries & mutations
Extensions can also contain 'backend functions', which are really just JS functions that will execute in their own Node.js process, and therefore have system access. You can use these functions to access information that is not accessible to you in the browser, such as to access the filesystem or interact with a database. For more information on how this works, please see the How extensions are loaded section below.
As an example, let's modify our hello world extension to read its own source code and show it in the browser:
import React from 'react';
import { useQuery } from '@karimsa/sidekick/extension';
import * as fs from 'fs';
async function readSelf() {
// This is possible because all sidekick extension functions will run from your project's
// root as its working directory.
return fs.promises.readFile('./hello.sidekick.tsx', 'utf-8');
}
export function Page() {
const { data, error } = useQuery(readSelf, {});
return (
<>
{error && <p>Error: {error.message}</p>}
{data && (
<pre>
<code>{data}</code>
</pre>
)}
</>
)
}
@karimsa/sidekick/extension
is a special module that you DO NOT need to install, and will be made available to your extension when it is run.When you run this extension, you will see the following:
You can also do this with mutations:
import React from 'react';
import * as fs from 'fs';
import { useMutation } from '@karimsa/sidekick/extension';
async function writeFile(content: string) {
return fs.promises.writeFile('./test.txt', content, 'utf-8');
}
export function Page() {
const [content, setContent] = React.useState('');
const { mutate: performWriteSelf } = useMutation(writeSelf);
return (
<>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<button onClick={() => performWriteSelf(content)}>Save</button>
</>
)
}
Both useQuery
and useMutation
wrap the methods by the same name from react-query
(opens in a new tab) so you can also pass additional options if you need to, but they provide some really nice builtins like queries will automatically refetch on focus and will also retry automatically.
useQuery
and useMutation
are not normal React hooks, and should not be composed. The sidekick bundler relies on tracing the uses of these hooks back to the @karimsa/sidekick/extension
module. If it finds unexpected uses of these hooks, it will complain and refuse to let your extension run.
How extensions are loaded
Sidekick performs some special bundling on your extension's entrypoint to allow for an optimal developer experience when developing extensions.
The first step that sidekick performs to bundle your extension is to rollup all of your extension code into a single file. This allows you to write your extension as you would a normal React app, across multiple files using TS, JSX, and any other features that you would expect from a modern React app.
Next, sidekick separates your combined extension file into a 'backend' and 'frontend' version. This is done by using the useQuery()
and useMutation()
calls as hints for sidekick to know which parts belong to the backend vs. frontend. The function that is passed to these hooks as the first parameter must always be a top-level declared async function (it can come from another file, but must be top-level in that file). These functions are assumed to be backend code, and split from the frontend code. The Page
component export is considered to be the entrypoint to your extension, and anything that Page
does not rely on is tree-shaken to be removed from your app.
The hello world example that we worked with above might be separated like this:
Frontend
import React from 'react';
import { useQuery } from '@karimsa/sidekick/extension';
export function Page() {
const { data, error } = useQuery('readSelf', {});
return (
<>
{error && <p>Error: {error.message}</p>}
{data && (
<pre>
<code>{data}</code>
</pre>
)}
</>
)
}
Backend
import * as fs from 'fs';
export async function readSelf() {
// This is possible because all sidekick extension functions will run from your project's
// root as its working directory.
return fs.promises.readFile('./hello.sidekick.tsx', 'utf-8');
}
The above code examples are not the actual code that sidekick would output, but are meant to illustrate how the bundling process works.
The following is a more detailed breakdown of the bundling pipeline used internally by sidekick:
Extension helpers (Reference)
All of the sidekick helpers use react-query (opens in a new tab). To stay consistent with react-query patterns, all of the sidekick hooks try to maintain a similar result data shape as the react-query hooks.
useQuery
A React hook that can be used to execute read-only backend operations. This provides you with some sane defaults that provide an optimal UX:
- The backend function passed to this hook will be automatically executed as the component renders.
- If there are failures, they will be automatically retried (up to 3 times).
- If the user focuses away and then refocuses on the page, the query will automatically refetch.
Retry behaviours, refetch on focus, and other options can be controlled by passing an options
object as the third parameter to useQuery()
, which is passed forward to react-query
(opens in a new tab).
useQueryInvalidator
A React hook that can be used to force a query on a different part of the page to refetch. Currently, this performs a wildcard invalidation, meaning that all queries that rely on the given backend function will be refreshed.
useMutation
A React hook that can be used to execute read-write backend operations. This provides you with some sane defaults that provide an optimal UX:
- The backend function passed to this hook will not be automatically executed, but rather run when the
mutate()
function returned by this hook is called. - Failures will not be retried.
Similar to useQuery
, this hook accepts a third options
parameter that is passed through to react-query
(opens in a new tab).
useConfig
A React hook that can be used to read the configuration for your extension. This is useful if you want to provide some configuration options to your extension, such as a list of allowed file extensions, or a list of allowed file paths.
useTargetEnvironments
A React hook that will return a list of the target environments that the user has configured in their sidekick (i.e. local
, staging
, production
). This is useful because both useQuery
and useMutation