├── .prettierignore ├── pnpm-workspace.yaml ├── modules ├── extensions-react │ ├── jest-setup.js │ ├── src │ │ ├── components │ │ │ ├── index.tsx │ │ │ └── HandshakeProvider.tsx │ │ ├── index.ts │ │ ├── hooks │ │ │ ├── useIsExtension.ts │ │ │ ├── index.ts │ │ │ ├── useThemeValues.ts │ │ │ ├── useActiveFile.ts │ │ │ ├── useReplitEffect.ts │ │ │ ├── useTheme.ts │ │ │ ├── useReplit.ts │ │ │ ├── useSetThemeCssVariables.ts │ │ │ └── useWatchTextFile.ts │ │ ├── state │ │ │ ├── store.ts │ │ │ ├── filePath.ts │ │ │ └── connection.ts │ │ └── types │ │ │ └── index.ts │ ├── jest.config.js │ ├── tsconfig.json │ ├── changelog.md │ ├── buildTests │ │ └── build.test.ts │ ├── package.json │ └── README.md ├── extensions │ ├── src │ │ ├── api │ │ │ ├── internal │ │ │ │ └── index.ts │ │ │ ├── experimental │ │ │ │ ├── index.ts │ │ │ │ ├── editor.ts │ │ │ │ └── auth.ts │ │ │ ├── me.ts │ │ │ ├── index.ts │ │ │ ├── session.ts │ │ │ ├── replDb.ts │ │ │ ├── theme.ts │ │ │ ├── commands.ts │ │ │ ├── messages.ts │ │ │ ├── data.ts │ │ │ ├── exec.ts │ │ │ ├── debug.ts │ │ │ └── fs │ │ │ │ ├── index.ts │ │ │ │ └── watching.ts │ │ ├── types │ │ │ ├── session.ts │ │ │ ├── auth.ts │ │ │ ├── exec.ts │ │ │ ├── data.ts │ │ │ ├── fs.ts │ │ │ ├── themes.ts │ │ │ └── index.ts │ │ ├── util │ │ │ ├── handshake.ts │ │ │ ├── comlink.ts │ │ │ └── patchConsole.ts │ │ ├── auth │ │ │ ├── base64.ts │ │ │ ├── verify.ts │ │ │ └── ed25519.ts │ │ ├── index.ts │ │ ├── apis.json │ │ └── commands │ │ │ └── index.ts │ ├── tsconfig.json │ ├── jest-setup-jsdom.js │ ├── jest.config.js │ ├── README.md │ ├── package.json │ ├── buildTests │ │ └── build.test.ts │ ├── util │ │ └── signature-plugin │ │ │ └── index.cjs │ └── changelog.md ├── dev │ ├── public │ │ ├── cover.png │ │ └── extension.json │ ├── tsconfig.json │ ├── src │ │ ├── index.tsx │ │ ├── App.tsx │ │ └── App.css │ ├── vite.config.js │ ├── index.html │ └── package.json └── tester │ ├── public │ ├── icon.png │ ├── cover.png │ └── extension.json │ ├── tsconfig.json │ ├── vite.config.js │ ├── src │ ├── index.tsx │ ├── tests │ │ ├── me.ts │ │ ├── session.ts │ │ ├── editor.ts │ │ ├── index.ts │ │ ├── debug.ts │ │ ├── exec.ts │ │ ├── themes.ts │ │ ├── replDb.ts │ │ ├── messages.ts │ │ ├── actionRequired.ts │ │ ├── data.ts │ │ └── fs.ts │ ├── utils │ │ ├── assertions.ts │ │ └── tests.ts │ ├── types.ts │ ├── components │ │ ├── TestGroup.tsx │ │ ├── StateContext.tsx │ │ ├── Header.tsx │ │ └── Test.tsx │ ├── App.tsx │ └── App.css │ ├── index.html │ └── package.json ├── .gitignore ├── replit.nix ├── tsconfig.json ├── turbo.json ├── README.md ├── LICENSE.md ├── .replit ├── .github └── workflows │ └── ci.yaml ├── package.json └── contributing.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "modules/*" 3 | -------------------------------------------------------------------------------- /modules/extensions-react/jest-setup.js: -------------------------------------------------------------------------------- 1 | // @jest/why-are-you-causing-pain 2 | -------------------------------------------------------------------------------- /modules/extensions/src/api/internal/index.ts: -------------------------------------------------------------------------------- 1 | // internal API goes here 2 | export {}; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | dist/ 4 | .breakpoints 5 | docs/ 6 | .turbo 7 | extension_tester/ -------------------------------------------------------------------------------- /modules/dev/public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/replit/extensions/main/modules/dev/public/cover.png -------------------------------------------------------------------------------- /modules/extensions-react/src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { HandshakeProvider } from "./HandshakeProvider"; 2 | -------------------------------------------------------------------------------- /modules/tester/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/replit/extensions/main/modules/tester/public/icon.png -------------------------------------------------------------------------------- /modules/tester/public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/replit/extensions/main/modules/tester/public/cover.png -------------------------------------------------------------------------------- /modules/extensions/src/api/experimental/index.ts: -------------------------------------------------------------------------------- 1 | export * as auth from "./auth"; 2 | export * as editor from "./editor"; -------------------------------------------------------------------------------- /modules/extensions-react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; 2 | export * from "./types"; 3 | export * from "./components"; 4 | -------------------------------------------------------------------------------- /replit.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: { 2 | deps = [ 3 | pkgs.nodejs-18_x 4 | pkgs.nodePackages.typescript-language-server 5 | pkgs.yarn 6 | pkgs.nodePackages.pnpm 7 | ]; 8 | } 9 | -------------------------------------------------------------------------------- /modules/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "paths": { 6 | "src": ["./src/"] 7 | }, 8 | "jsx": "react-jsx" 9 | }, 10 | "extends": "../../tsconfig.json" 11 | } 12 | -------------------------------------------------------------------------------- /modules/tester/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "paths": { 6 | "src": ["./src/"] 7 | }, 8 | "jsx": "react-jsx" 9 | }, 10 | "extends": "../../tsconfig.json" 11 | } 12 | -------------------------------------------------------------------------------- /modules/dev/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /modules/extensions/src/types/session.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Fires when the current user switches to a different file/tool in the workspace. Returns null if the current file/tool cannot be found in the filesystem. 3 | */ 4 | export type OnActiveFileChangeListener = (file: string | null) => void; 5 | -------------------------------------------------------------------------------- /modules/extensions-react/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | transform: { 3 | "^.+\\.(ts|js)x?$": "esbuild-jest", 4 | }, 5 | testMatch: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[tj]s?(x)"], 6 | testEnvironment: "node", 7 | setupFilesAfterEnv: ["/jest-setup.js"], 8 | }; 9 | -------------------------------------------------------------------------------- /modules/extensions-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "src": ["./src/"] 8 | }, 9 | "jsx": "react-jsx" 10 | }, 11 | "extends": "../../tsconfig.json" 12 | } 13 | -------------------------------------------------------------------------------- /modules/extensions/src/api/me.ts: -------------------------------------------------------------------------------- 1 | import { extensionPort } from "../util/comlink"; 2 | 3 | /** 4 | * Returns the path to the current file the extension is opened with, if it is a [File Handler](/extensions/basics/key-concepts#file-handler). 5 | */ 6 | export function filePath() { 7 | return extensionPort.filePath; 8 | } 9 | -------------------------------------------------------------------------------- /modules/extensions/src/types/auth.ts: -------------------------------------------------------------------------------- 1 | export interface AuthenticatedInstallation { 2 | id: string; 3 | extensionId: string; 4 | } 5 | 6 | export interface AuthenticatedUser { 7 | id: number; 8 | } 9 | 10 | export interface AuthenticateResult { 11 | user: AuthenticatedUser; 12 | installation: AuthenticatedInstallation; 13 | } 14 | -------------------------------------------------------------------------------- /modules/extensions/src/util/handshake.ts: -------------------------------------------------------------------------------- 1 | import { HandshakeStatus } from "../types"; 2 | 3 | let handshakeStatus: HandshakeStatus = HandshakeStatus.Loading; 4 | 5 | export const setHandshakeStatus = (status: HandshakeStatus) => { 6 | handshakeStatus = status; 7 | }; 8 | 9 | export const getHandshakeStatus = () => handshakeStatus; 10 | -------------------------------------------------------------------------------- /modules/extensions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "src": ["./src/"] 8 | }, 9 | "types": [ 10 | "jest", 11 | "node" 12 | ] 13 | }, 14 | "extends": "../../tsconfig.json" 15 | } 16 | -------------------------------------------------------------------------------- /modules/dev/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | host: "0.0.0.0", 9 | }, 10 | optimizeDeps: { 11 | exclude: ["@replit/extensions", "@replit/extensions-react"], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /modules/tester/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | host: "0.0.0.0", 9 | }, 10 | optimizeDeps: { 11 | exclude: ["@replit/extensions", "@replit/extensions-react"], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /modules/extensions/src/api/experimental/editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorPreferences } from "../../types"; 2 | import { extensionPort } from "../../util/comlink"; 3 | 4 | /** 5 | * Returns the current user's editor preferences. 6 | */ 7 | export async function getPreferences(): Promise { 8 | return await extensionPort.experimental.editor.getPreferences(); 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "target": "ESNext", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "jsx": "react-jsx", 11 | "noUncheckedIndexedAccess": true, 12 | "types": ["jest", "node"] 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /modules/dev/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HandshakeStatus } from "@replit/extensions"; 2 | import { useReplit } from "@replit/extensions-react"; 3 | import "./App.css"; 4 | 5 | export default function App() { 6 | const { replit, status } = useReplit(); 7 | 8 | const connected = status === HandshakeStatus.Ready; 9 | 10 | return
{connected ? "Connected" : "Not Connected"}
; 11 | } 12 | -------------------------------------------------------------------------------- /modules/tester/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import AppStateProvider from "./components/StateContext"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"] 6 | }, 7 | "test:build": { 8 | "dependsOn": ["build"] 9 | }, 10 | "lint": {}, 11 | "clean": { 12 | "cache": false 13 | }, 14 | "lint:check": {}, 15 | "type:check": { 16 | "dependsOn": ["build"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /modules/extensions/src/util/comlink.ts: -------------------------------------------------------------------------------- 1 | import * as Comlink from "comlink"; 2 | import { ExtensionPort } from "../types"; 3 | 4 | export const extensionPort = (() => 5 | typeof window !== "undefined" 6 | ? (Comlink.wrap( 7 | Comlink.windowEndpoint(self.parent, self, "*") 8 | ) as any as ExtensionPort) 9 | : null)() as ExtensionPort; 10 | 11 | export const proxy = Comlink.proxy; 12 | export const releaseProxy = Comlink.releaseProxy; 13 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useIsExtension.ts: -------------------------------------------------------------------------------- 1 | import { HandshakeStatus } from "@replit/extensions"; 2 | import { useReplit } from "./useReplit"; 3 | 4 | /** 5 | * Returns whether your application is an extension once the handshake between Replit an your extension is established. When the handshake is loading, returns `undefined`. 6 | */ 7 | export default function useIsExtension(): boolean { 8 | const { status } = useReplit(); 9 | 10 | return status === HandshakeStatus.Ready; 11 | } 12 | -------------------------------------------------------------------------------- /modules/extensions-react/src/components/HandshakeProvider.tsx: -------------------------------------------------------------------------------- 1 | let warned = false; 2 | 3 | /** 4 | * @deprecated 5 | * You no longer need to wrap your app with HandshakeProvider 6 | */ 7 | export function HandshakeProvider({ children }: { children: React.ReactNode }) { 8 | if (!warned) { 9 | console.warn( 10 | "HandshakeProvider is deprecated and will be removed soon. You can simply remove it from your code." 11 | ); 12 | warned = true; 13 | } 14 | 15 | return children; 16 | } 17 | -------------------------------------------------------------------------------- /modules/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Replit Extensions API Dev env 7 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /modules/tester/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Replit Extensions API Tester 7 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /modules/tester/src/tests/me.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { me } from "@replit/extensions"; 3 | import { assert } from "chai"; 4 | 5 | const tests: TestObject = { 6 | "filePath should be a string (file handler) or null (tool)": async () => { 7 | const res = await me.filePath(); 8 | 9 | assert.isTrue(typeof res === "string" || res === null); 10 | }, 11 | }; 12 | 13 | const MeTests: TestNamespace = { 14 | module: "me", 15 | tests, 16 | }; 17 | 18 | export default MeTests; 19 | -------------------------------------------------------------------------------- /modules/tester/src/tests/session.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { session } from "@replit/extensions"; 3 | import { assert } from "chai"; 4 | 5 | const tests: TestObject = { 6 | "getActiveFile should return the active file / null": async () => { 7 | const res = await session.getActiveFile(); 8 | 9 | assert.isTrue(typeof res === "string" || res === null); 10 | }, 11 | }; 12 | 13 | const SessionTests: TestNamespace = { 14 | module: "session", 15 | tests, 16 | }; 17 | 18 | export default SessionTests; 19 | -------------------------------------------------------------------------------- /modules/extensions/jest-setup-jsdom.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | 3 | // jsdom needs this polyfill 4 | import { TextEncoder, TextDecoder } from "util"; 5 | global.TextEncoder = TextEncoder; 6 | global.TextDecoder = TextDecoder; 7 | 8 | // can't be import because imports are hoisted 9 | const { JSDOM } = require("jsdom"); 10 | 11 | const dom = new JSDOM("", { 12 | runScripts: "dangerously", 13 | resources: "usable", 14 | }); 15 | 16 | global.window = dom.window; 17 | global.document = dom.window.document; 18 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useThemeValues from "./useThemeValues"; 2 | import useReplitEffect from "./useReplitEffect"; 3 | import useActiveFile from "./useActiveFile"; 4 | import useTheme from "./useTheme"; 5 | import useIsExtension from "./useIsExtension"; 6 | import useSetThemeCssVariables from "./useSetThemeCssVariables"; 7 | 8 | export * from "./useReplit"; 9 | export * from "./useWatchTextFile"; 10 | 11 | export { 12 | // General purpose hooks 13 | useReplitEffect, 14 | useActiveFile, 15 | useIsExtension, 16 | // Theme hooks 17 | useThemeValues, 18 | useSetThemeCssVariables, 19 | useTheme, 20 | }; 21 | -------------------------------------------------------------------------------- /modules/extensions-react/changelog.md: -------------------------------------------------------------------------------- 1 | ### 0.6.0 2 | 3 | - Removed handshake provider 4 | 5 | ### 0.5.0 6 | 7 | - Add useSetThemeCssVariables hook 8 | 9 | ### Note 10 | 11 | We're going back to 0.x releases because the package isn't in a good shape 12 | 13 | ### 1.1.1 14 | 15 | - Fix a dependency tie-up 16 | 17 | ### 1.1.0 18 | 19 | - Make the react lib use the latest version 20 | 21 | ### 1.0.0 22 | 23 | - First initial Release of the React lib 24 | 25 | ### 0.1.0-beta.0 26 | 27 | - Trying things out with the `` component, added doc strings to the rest of the components. 28 | 29 | ### 0.0.1 30 | 31 | - Still a work in progress 32 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useThemeValues.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ThemeValuesGlobal } from "@replit/extensions"; 3 | import useReplitEffect from "./useReplitEffect"; 4 | 5 | /** 6 | * Returns the global tokens of the current user's theme. 7 | */ 8 | export default function useThemeValues() { 9 | const [values, setValues] = useState(null); 10 | 11 | useReplitEffect(async ({ themes }) => { 12 | const themeValues = await themes.getCurrentThemeValues(); 13 | 14 | setValues(themeValues); 15 | 16 | await themes.onThemeChangeValues(setValues); 17 | }, []); 18 | 19 | return values; 20 | } 21 | -------------------------------------------------------------------------------- /modules/tester/src/tests/editor.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { experimental } from "@replit/extensions"; 3 | import { assert } from "chai"; 4 | 5 | const { editor } = experimental; 6 | 7 | const tests: TestObject = { 8 | "getPreferences returns the current editor preferences": async (log) => { 9 | const res = await editor.getPreferences(); 10 | 11 | assert.isObject(res); 12 | assert.isNumber(res.fontSize); 13 | 14 | for (const [key, value] of Object.entries(res)) { 15 | log(`${key}: ${value}`); 16 | } 17 | }, 18 | }; 19 | 20 | const EditorTests: TestNamespace = { 21 | module: "editor", 22 | tests, 23 | }; 24 | 25 | export default EditorTests; 26 | -------------------------------------------------------------------------------- /modules/tester/src/tests/index.ts: -------------------------------------------------------------------------------- 1 | import data from "./data"; 2 | import fs from "./fs"; 3 | import me from "./me"; 4 | import messages from "./messages"; 5 | import session from "./session"; 6 | import replDb from "./replDb"; 7 | import themes from "./themes"; 8 | import editor from "./editor"; 9 | import exec from "./exec"; 10 | import debug from "./debug"; 11 | import actionRequired from "./actionRequired"; 12 | import { Module, TestNamespace } from "../types"; 13 | 14 | const UnitTests: Record = { 15 | fs, 16 | me, 17 | messages, 18 | session, 19 | replDb, 20 | themes, 21 | editor, 22 | exec, 23 | data, 24 | actionRequired, 25 | debug, 26 | }; 27 | 28 | export default UnitTests; 29 | -------------------------------------------------------------------------------- /modules/extensions/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | projects: [ 3 | { 4 | displayName: "buildTests", 5 | transform: { 6 | "^.+\\.(ts|js)x?$": "esbuild-jest", 7 | }, 8 | testMatch: ["/buildTests/**.test.ts"], 9 | testEnvironment: "node", 10 | setupFilesAfterEnv: ["/jest-setup-jsdom.js"], 11 | }, 12 | { 13 | displayName: "tests", 14 | transform: { 15 | "^.+\\.(ts|js)x?$": "esbuild-jest", 16 | }, 17 | testMatch: [ 18 | "/src/**/__tests__/**/*.[jt]s?(x)", 19 | "/src/**/?(*.)+(spec|test).[tj]s?(x)", 20 | ], 21 | testPathIgnorePatterns: ["buildTests"], 22 | testEnvironment: "node", 23 | }, 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /modules/extensions-react/src/state/store.ts: -------------------------------------------------------------------------------- 1 | import { Atom } from "jotai"; 2 | import { useAtomValue, useSetAtom, useAtom } from "jotai/react"; 3 | import { createStore } from "jotai"; 4 | import React from "react"; 5 | 6 | export const store = createStore(); 7 | 8 | export const useValue: typeof useAtomValue = (atom: Atom) => { 9 | return useAtomValue(atom, { store }); 10 | }; 11 | 12 | export const useSet: typeof useSetAtom = (atom: any) => { 13 | return useSetAtom(atom, { store }); 14 | }; 15 | 16 | export const useGet = (atom: Atom) => { 17 | return React.useMemo(() => { 18 | return store.get(atom); 19 | }, [store, atom]); 20 | }; 21 | 22 | export const use: typeof useAtom = (atom: Atom) => { 23 | return useAtom(atom, { store }); 24 | }; 25 | -------------------------------------------------------------------------------- /modules/extensions/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "./fs"; 2 | import * as replDb from "./replDb"; 3 | import * as me from "./me"; 4 | import * as themes from "./theme"; 5 | import * as messages from "./messages"; 6 | import * as data from "./data"; 7 | import * as session from "./session"; 8 | import * as experimental from "./experimental"; 9 | import * as internal from "./internal"; 10 | import * as exec from "./exec"; 11 | import * as debug from "./debug"; 12 | import * as commands from "./commands"; 13 | 14 | export { 15 | fs, 16 | replDb, 17 | me, 18 | themes, 19 | messages, 20 | data, 21 | session, 22 | experimental, 23 | internal, 24 | exec, 25 | debug, 26 | commands, 27 | }; 28 | 29 | // deprecate this after migrating existing extensions 30 | export * from "./fs"; 31 | -------------------------------------------------------------------------------- /modules/extensions/src/api/session.ts: -------------------------------------------------------------------------------- 1 | import { DisposerFunction, OnActiveFileChangeListener } from "../types"; 2 | import { extensionPort, proxy } from "../util/comlink"; 3 | 4 | /** 5 | * Sets up a listener to handle when the active file is changed 6 | */ 7 | export function onActiveFileChange( 8 | listener: OnActiveFileChangeListener 9 | ): DisposerFunction { 10 | let dispose = () => { 11 | console.log("disposing existing watcher"); 12 | }; 13 | 14 | extensionPort.watchActiveFile(proxy(listener)).then((d: () => void) => { 15 | dispose = d; 16 | }); 17 | 18 | return () => { 19 | dispose(); 20 | }; 21 | } 22 | 23 | /** 24 | * Returns the current file the user is focusing 25 | */ 26 | export async function getActiveFile() { 27 | return await extensionPort.getActiveFile(); 28 | } 29 | -------------------------------------------------------------------------------- /modules/extensions-react/buildTests/build.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | describe("dist/index.cjs (CommonJS)", () => { 4 | test("exists", () => { 5 | expect(fs.existsSync("./dist/index.cjs")).toBe(true); 6 | }); 7 | test("sourcemap file exists", () => { 8 | expect(fs.existsSync("./dist/index.cjs.map")).toBe(true); 9 | }); 10 | }); 11 | 12 | describe("dist/index.js (ES Module)", () => { 13 | test("exists", () => { 14 | expect(fs.existsSync("./dist/index.js")).toBe(true); 15 | }); 16 | test("sourcemap file exists", () => { 17 | expect(fs.existsSync("./dist/index.js.map")).toBe(true); 18 | }); 19 | }); 20 | 21 | describe("dist/index.d.ts (TypeScript defs)", () => { 22 | test("exists", () => { 23 | expect(fs.existsSync("./dist/index.d.ts")).toBe(true); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useActiveFile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { session } from "@replit/extensions"; 3 | import { useReplit } from "./useReplit"; 4 | 5 | /** 6 | * Returns the path to the current file the user is focusing, if it exists in the filesystem. 7 | */ 8 | export default function useActiveFile() { 9 | const [file, setFile] = useState(null); 10 | 11 | const { status } = useReplit(); 12 | 13 | useEffect(() => { 14 | if (status === "ready") { 15 | (async () => { 16 | setFile(await session.getActiveFile()); 17 | })(); 18 | 19 | return session.onActiveFileChange(async (f: string | null) => setFile(f)); 20 | } else { 21 | return () => {}; 22 | } 23 | }, [status]); 24 | 25 | return file; 26 | } 27 | -------------------------------------------------------------------------------- /modules/extensions-react/src/state/filePath.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { me } from "@replit/extensions"; 3 | import { useSet, useValue } from "./store"; 4 | import React from "react"; 5 | 6 | export const filePathAtom = atom(null); 7 | 8 | export const setFilePathAtom = atom(null, (_get, set, path: string) => { 9 | set(filePathAtom, path); 10 | }); 11 | 12 | export const fetchFilePathAtom = atom(null, async (get, set) => { 13 | const filePath = await me.filePath(); 14 | 15 | set(filePathAtom, filePath); 16 | }); 17 | 18 | export function useFilePath() { 19 | const fetchFilePath = useSet(fetchFilePathAtom); 20 | const filePath = useValue(filePathAtom); 21 | 22 | return React.useMemo( 23 | () => ({ 24 | filePath, 25 | fetchFilePath, 26 | }), 27 | [filePath, fetchFilePath] 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /modules/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extensions-api-dev", 3 | "version": "1.0.0", 4 | "description": "Package to play around with @replit/extensions", 5 | "scripts": { 6 | "dev": "vite", 7 | "lint": "npx prettier --write src/*", 8 | "lint:check": "npx prettier -l src/*", 9 | "type:check": "tsc --noEmit", 10 | "build": "vite build" 11 | }, 12 | "author": "lunaroyster", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@replit/extensions": "workspace:*", 16 | "@replit/extensions-react": "workspace:*", 17 | "@vitejs/plugin-react": "^4.0.0", 18 | "chai": "^4.3.7", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-feather": "^2.0.10", 22 | "vite": "^4.2.1" 23 | }, 24 | "devDependencies": { 25 | "@types/chai": "^4.3.5", 26 | "@types/react": "^18.2.0", 27 | "@types/react-dom": "^18.2.4", 28 | "prettier": "^2.7.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /modules/tester/src/utils/assertions.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | // Makes sure a file's contents is a string and that no errors have occured. 4 | export function assertFileContents( 5 | file: 6 | | { 7 | content: string; 8 | } 9 | | { error: string } 10 | ) { 11 | if ("content" in file) { 12 | assert.isString(file.content); 13 | 14 | return file.content; 15 | } else { 16 | throw new Error("Expected a string"); 17 | } 18 | } 19 | 20 | // Makes sure a path/file name is prefixed with the test directory 21 | export function assertPathOrNameValidity( 22 | pathOrName: string 23 | ): asserts pathOrName is string { 24 | if (!pathOrName.startsWith("extension_tester")) { 25 | throw new Error("Test files must be prefixed with extension_tester"); 26 | } 27 | } 28 | 29 | // Generate a random string 30 | export const randomString = () => Math.random().toString(36).slice(2); 31 | -------------------------------------------------------------------------------- /modules/tester/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extensions-api-tester", 3 | "version": "1.0.0", 4 | "description": "Runs all the available Replit Extension APIs, confirming that each one works.", 5 | "scripts": { 6 | "dev": "vite", 7 | "lint": "npx prettier --write src/*", 8 | "lint:check": "npx prettier -l src/*", 9 | "type:check": "tsc --noEmit", 10 | "build": "vite build" 11 | }, 12 | "author": "lunaroyster", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@replit/extensions": "workspace:*", 16 | "@replit/extensions-react": "workspace:*", 17 | "@vitejs/plugin-react": "^4.0.0", 18 | "chai": "^4.3.7", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-feather": "^2.0.10", 22 | "vite": "^4.2.1" 23 | }, 24 | "devDependencies": { 25 | "@types/chai": "^4.3.5", 26 | "@types/react": "^18.2.0", 27 | "@types/react-dom": "^18.2.4", 28 | "prettier": "^2.7.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /modules/tester/src/tests/debug.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { debug } from "@replit/extensions"; 3 | 4 | const tests: TestObject = { 5 | "info should log general information to the Extension Devtools Logs": 6 | async () => { 7 | debug.info("[Extension Tester (info)]", { 8 | this: { 9 | is: "test info", 10 | }, 11 | }); 12 | }, 13 | "warn should log a warning to the Extension Devtools Logs": async (log) => { 14 | debug.warn("[Extension Tester (warn)]", { 15 | this: { 16 | is: "test warning", 17 | }, 18 | }); 19 | }, 20 | "error should log an error to the Extension Devtools Logs": async (log) => { 21 | debug.error("[Extension Tester (error)]", { 22 | this: { 23 | is: "test error", 24 | }, 25 | }); 26 | }, 27 | }; 28 | 29 | const ExecTests: TestNamespace = { 30 | module: "debug", 31 | tests, 32 | }; 33 | 34 | export default ExecTests; 35 | -------------------------------------------------------------------------------- /modules/extensions-react/src/state/connection.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { HandshakeStatus } from "@replit/extensions"; 3 | import { init } from "@replit/extensions"; 4 | 5 | export const statusAtom = atom(HandshakeStatus.Loading); 6 | 7 | export const connectionErrorAtom = atom(null); 8 | 9 | export const connectionDisposeAtom = atom<(() => void) | null>(null); 10 | 11 | export const connectAtom = atom(null, async (get, set) => { 12 | try { 13 | if (get(statusAtom) === HandshakeStatus.Ready) { 14 | console.warn("Already connected"); 15 | return; 16 | } 17 | 18 | const res = await init({ timeout: 2000 }); 19 | 20 | if (res.status !== HandshakeStatus.Ready) { 21 | throw new Error("Handshake failed"); 22 | } 23 | 24 | set(statusAtom, HandshakeStatus.Ready); 25 | set(connectionDisposeAtom, res.dispose); 26 | } catch (e) { 27 | set(statusAtom, HandshakeStatus.Error); 28 | set(connectionErrorAtom, e as Error); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Replit Extensions Monorepo (All Clients) 2 | 3 | A monorepo containing all NPM packages related to Replit Extensions. 4 | 5 | - NPM Packages 6 | - [Default API Client](https://www.npmjs.com/package/@replit/extensions) 7 | - [React API Client](https://www.npmjs.com/package/@replit/extensions-react) 8 | - [Repository](https://github.com/replit/extensions) 9 | - [Documentation](https://docs.replit.com/extensions) 10 | - [API Modules](https://docs.replit.com/extensions/category/api-reference) 11 | - [React Client](https://docs.replit.com/extensions/category/react) 12 | - [Discourse Category](https://ask.replit.com/c/extensions) 13 | - [React Extension Template](https://replit.com/@replit/React-Extension?v=1) 14 | - [HTML/CSS/JS Extension Template](https://replit.com/@replit/HTMLCSSJS-Extension?v=1) 15 | 16 | ## Getting Started 17 | 18 | 1. Clone this repository 19 | 2. Run `pnpm install` 20 | 3. Run `pnpm dev`, or simply hit the Run button if you've imported this into Replit. 21 | 22 | Edit the in the `modules` folder and your changes will be previewed live via Hot Module Reloading. 23 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useReplitEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { HandshakeStatus } from "@replit/extensions"; 3 | import type * as replit from "@replit/extensions"; 4 | import { useReplit } from "./useReplit"; 5 | 6 | /** 7 | * Fires a callback with the Replit API wrapper when its dependency array changes. 8 | * Similar in functionality to the React useEffect hook, supports cleanup disposers. 9 | */ 10 | export default function useReplitEffect( 11 | callback: ( 12 | r: typeof replit 13 | ) => void | Promise | (() => void) | Promise<() => void>, 14 | dependencies?: Array 15 | ) { 16 | const { replit, status } = useReplit(); 17 | 18 | return useEffect( 19 | () => { 20 | if (!replit || status === HandshakeStatus.Ready) { 21 | return; 22 | } 23 | 24 | const dispose = callback(replit); 25 | 26 | if (typeof dispose !== "function") { 27 | return; 28 | } 29 | 30 | return () => { 31 | dispose(); 32 | }; 33 | }, 34 | dependencies ? [...dependencies, replit, status] : undefined 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Replit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /modules/extensions/src/util/patchConsole.ts: -------------------------------------------------------------------------------- 1 | import * as debug from "../api/debug"; 2 | 3 | const consoleIsPatchedSymbol = Symbol("consoleIsPatched"); 4 | 5 | export function patchConsole() { 6 | if (isConsolePatched()) { 7 | return; 8 | } 9 | 10 | const originalLog = console.log; 11 | const originalWarn = console.warn; 12 | const originalError = console.error; 13 | const originalInfo = console.info; 14 | 15 | console.log = (...args: any[]) => { 16 | originalLog(...args); 17 | debug.log(args[0], { args: args.slice(1) }); 18 | }; 19 | 20 | console.warn = (...args: any[]) => { 21 | originalWarn(...args); 22 | debug.warn(args[0], { args: args.slice(1) }); 23 | }; 24 | 25 | console.error = (...args: any[]) => { 26 | originalError(...args); 27 | debug.error(args[0], { args: args.slice(1) }); 28 | }; 29 | 30 | console.info = (...args: any[]) => { 31 | originalInfo(...args); 32 | debug.info(args[0], { args: args.slice(1) }); 33 | }; 34 | 35 | // @ts-ignore 36 | console[consoleIsPatchedSymbol] = true; 37 | } 38 | 39 | export function isConsolePatched() { 40 | // @ts-ignore 41 | return Boolean(console[consoleIsPatchedSymbol]); 42 | } 43 | -------------------------------------------------------------------------------- /modules/tester/src/tests/exec.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { exec, fs } from "@replit/extensions"; 3 | import { assert } from "chai"; 4 | import { assertFileExists, createTestDirIfNotExists } from "../utils/tests"; 5 | 6 | const tests: TestObject = { 7 | "exec should run a bash command": async () => { 8 | await createTestDirIfNotExists(); 9 | 10 | await exec.exec("touch extension_tester/test_text_file.txt"); 11 | const res = await assertFileExists("extension_tester/test_text_file.txt"); 12 | 13 | assert.isString(res.content); 14 | 15 | // Cleanup 16 | await fs.deleteFile("extension_tester/test_text_file.txt"); 17 | }, 18 | "spawn should successfully spawn a connection instance": async (log) => { 19 | const { resultPromise, dispose } = exec.spawn({ 20 | args: ["echo", "hello"], 21 | onOutput: (output) => { 22 | assert.isString(output); 23 | log("> " + output); 24 | }, 25 | }); 26 | 27 | await resultPromise; 28 | 29 | // Cleanup 30 | dispose(); 31 | }, 32 | }; 33 | 34 | const ExecTests: TestNamespace = { 35 | module: "exec", 36 | tests, 37 | }; 38 | 39 | export default ExecTests; 40 | -------------------------------------------------------------------------------- /modules/dev/public/extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Extensions API Dev Environment", 3 | "description": "Dev env for testing @replit/extensions", 4 | "tags": ["extensions", "replit", "tester"], 5 | "website": "https://github.com/replit/extensions", 6 | "coverImages": [ 7 | 8 | { 9 | path: "./cover.png", 10 | label: "preview of extension" 11 | } 12 | ], 13 | "icon": "./icon.png", 14 | "tools": [ 15 | { 16 | "name": "Extensions API Dev", 17 | "handler": "/", 18 | "icon": "./icon.png" 19 | } 20 | ], 21 | "scopes": [ 22 | { 23 | "name": "read", 24 | "reason": "Required to run and test the `fs` API module." 25 | }, 26 | { 27 | "name": "write-exec", 28 | "reason": "Required to run and test the `fs` and `exec` API modules." 29 | }, 30 | { 31 | "name": "repldb:read", 32 | "reason": "Required to run and test the `replDb` API module." 33 | }, 34 | { 35 | "name": "repldb:write", 36 | "reason": "Required to run and test the `replDb` API module." 37 | }, 38 | { 39 | "name": "experimental-api", 40 | "reason": "Required to run and test experimental APIs" 41 | } 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /modules/extensions/src/api/replDb.ts: -------------------------------------------------------------------------------- 1 | import { extensionPort } from "../util/comlink"; 2 | 3 | /** 4 | * Sets the value for a given key. Required [permissions](/extensions/api/manifest#scopetype): `repldb:read`, `repldb:write`. 5 | */ 6 | export async function set(args: { key: string; value: any }) { 7 | return extensionPort.setReplDbValue(args.key, args.value); 8 | } 9 | 10 | /** 11 | * Returns a value associated with the given key. Required [permissions](/extensions/api/manifest#scopetype): `repldb:read`. 12 | */ 13 | export async function get(args: { key: string }) { 14 | return extensionPort.getReplDbValue(args.key); 15 | } 16 | 17 | /** 18 | * Lists keys in the replDb. Accepts an optional `prefix`, which filters for keys beginning with the given prefix. Required [permissions](/extensions/api/manifest#scopetype): `repldb:read`. 19 | */ 20 | export async function list(args: { prefix?: string } = {}) { 21 | return extensionPort.listReplDbKeys(args?.prefix || ""); 22 | } 23 | 24 | /** 25 | * Deletes a key in the replDb. Required [permissions](/extensions/api/manifest#scopetype): `repldb:read`, `repldb:write`. 26 | */ 27 | export async function del(args: { key: string }) { 28 | return extensionPort.deleteReplDbKey(args.key); 29 | } 30 | -------------------------------------------------------------------------------- /modules/extensions/README.md: -------------------------------------------------------------------------------- 1 | # Replit Extensions API Client 2 | 3 | The Replit Extensions client is a module that allows you to easily interact with the Workspace. 4 | 5 | - NPM Packages 6 | - https://www.npmjs.com/package/@replit/extensions 7 | - https://www.npmjs.com/package/@replit/extensions-react 8 | - [Repository](https://github.com/replit/extensions) 9 | - [Documentation](https://docs.replit.com/extensions) 10 | - [Resources](https://docs.replit.com/extensions/resources) 11 | - [API Modules](https://docs.replit.com/extensions/category/api-reference) 12 | - [React Client](https://docs.replit.com/extensions/category/react) 13 | - [Discourse Category](https://ask.replit.com/c/extensions) 14 | - [React Extension Template](https://replit.com/@replit/React-Extension?v=1) 15 | - [HTML/CSS/JS Extension Template](https://replit.com/@replit/HTMLCSSJS-Extension?v=1) 16 | 17 | [![Run on Replit button](https://user-images.githubusercontent.com/50180265/228865994-ccf7348e-ffb7-454e-bc4e-ce90df6c09bc.png)](https://replit.com/github/replit/extensions) 18 | 19 | ## Help 20 | 21 | If you don't understand something in the documentation, have found a bug, or would like to request a feature, you can get help on the [Ask Forum](https://ask.replit.com/c/extensions). 22 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HandshakeStatus, ThemeVersion } from "@replit/extensions"; 3 | import { useReplit } from "./useReplit"; 4 | 5 | /** 6 | * Returns the information on the current user's theme including global tokens, syntax highlighting modifiers, and metadata. 7 | */ 8 | export default function useTheme() { 9 | const [theme, setTheme] = React.useState(null); 10 | 11 | const { status, replit } = useReplit(); 12 | 13 | const connected = status === HandshakeStatus.Ready; 14 | 15 | React.useEffect(() => { 16 | if (!connected) { 17 | return; 18 | } 19 | 20 | let themeDispose: null | (() => void) = null; 21 | let dispose = () => { 22 | if (themeDispose) { 23 | themeDispose(); 24 | themeDispose = null; 25 | } 26 | }; 27 | 28 | (async () => { 29 | if (!replit) { 30 | return; 31 | } 32 | 33 | const th: ThemeVersion = await replit.themes.getCurrentTheme(); 34 | setTheme(th); 35 | themeDispose = await replit.themes.onThemeChange( 36 | (_theme: ThemeVersion) => { 37 | setTheme(_theme); 38 | } 39 | ); 40 | })(); 41 | 42 | return dispose; 43 | }, [replit]); 44 | 45 | return theme; 46 | } 47 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | run = "pnpm dev" 2 | hidden = [".config"] 3 | entrypoint = "modules/example/App.tsx" 4 | 5 | [[hints]] 6 | regex = "Error \\[ERR_REQUIRE_ESM\\]" 7 | message = "We see that you are using require(...) inside your code. We currently do not support this syntax. Please use 'import' instead when using external modules. (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)" 8 | 9 | [nix] 10 | channel = "stable-22_11" 11 | 12 | [env] 13 | XDG_CONFIG_HOME = "/home/runner/.config" 14 | PATH = "/home/runner/$REPL_SLUG/.config/npm/node_global/bin:/home/runner/$REPL_SLUG/node_modules/.bin" 15 | npm_config_prefix = "/home/runner/$REPL_SLUG/.config/npm/node_global" 16 | 17 | [extension] 18 | isExtension = true 19 | buildCommand = "cd modules/dev && pnpm build" 20 | outputDirectory = "modules/dev/dist" 21 | extensionID = "b99141f4-09df-43de-ad74-26c5685a55d1" 22 | 23 | [unitTest] 24 | language = "nodejs" 25 | 26 | [languages.javascript] 27 | pattern = "**/{*.js,*.jsx,*.ts,*.tsx}" 28 | 29 | [languages.javascript.languageServer] 30 | start = [ "typescript-language-server", "--stdio" ] 31 | 32 | [packager] 33 | language = "nodejs" 34 | 35 | [packager.features] 36 | enabledForHosting = false 37 | packageSearch = true 38 | guessImports = false 39 | 40 | [gitHubImport] 41 | requiredFiles = [".replit", "replit.nix", ".config"] 42 | -------------------------------------------------------------------------------- /modules/tester/src/tests/themes.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { themes } from "@replit/extensions"; 3 | import { assert } from "chai"; 4 | 5 | const tests: TestObject = { 6 | "getCurrentTheme gets the current theme": async (log) => { 7 | const res = await themes.getCurrentTheme(); 8 | 9 | assert.isObject(res); 10 | assert.isTrue( 11 | typeof res.id === "number" || 12 | ["replitDark", "replitLight"].includes(res.id) 13 | ); 14 | 15 | if (res.customTheme) { 16 | assert.isTrue( 17 | res.customTheme?.colorScheme === "dark" || 18 | res.customTheme?.colorScheme === "light" 19 | ); 20 | assert.isString(res.customTheme?.title); 21 | assert.isString(res.customTheme?.author.username); 22 | } 23 | 24 | log( 25 | `Theme: ${res.customTheme?.title} by ${res.customTheme?.author.username} (${res.customTheme?.colorScheme})` 26 | ); 27 | }, 28 | "getCurrentThemeValues gets the current theme values": async () => { 29 | const res = await themes.getCurrentThemeValues(); 30 | 31 | assert.isObject(res); 32 | assert.isTrue(Object.values(res).every((t) => typeof t === "string")); 33 | }, 34 | }; 35 | 36 | const ThemeTests: TestNamespace = { 37 | module: "themes", 38 | tests, 39 | }; 40 | 41 | export default ThemeTests; 42 | -------------------------------------------------------------------------------- /modules/tester/src/types.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import UnitTests from "./tests"; 3 | 4 | export type Module = 5 | | "fs" 6 | | "me" 7 | | "data" 8 | | "editor" 9 | | "exec" 10 | | "messages" 11 | | "replDb" 12 | | "session" 13 | | "themes" 14 | | "actionRequired" 15 | | "debug"; 16 | 17 | export interface Test { 18 | module: Module; 19 | key: keyof (typeof UnitTests)[Module]; 20 | status: "passed" | "failed" | "loading" | "idle"; 21 | shouldRun: boolean; 22 | } 23 | 24 | export interface TestNamespace { 25 | module: Module; 26 | tests: TestObject; 27 | } 28 | 29 | export type TestObject = Record< 30 | string, 31 | (log: (t: string) => void) => Promise 32 | >; 33 | 34 | export interface AppState { 35 | tests: Array; 36 | testQueue: Array>; 37 | setTestQueue: React.Dispatch< 38 | React.SetStateAction>> 39 | >; 40 | logs: Array; 41 | setLogs: React.Dispatch>>; 42 | passedTests: number | null; 43 | setPassedTests: React.Dispatch>; 44 | failedTests: number | null; 45 | setFailedTests: React.Dispatch>; 46 | totalTests: number | null; 47 | setTotalTests: React.Dispatch>; 48 | } 49 | -------------------------------------------------------------------------------- /modules/extensions/src/api/theme.ts: -------------------------------------------------------------------------------- 1 | import { proxy } from "comlink"; 2 | import { 3 | DisposerFunction, 4 | OnThemeChangeListener, 5 | OnThemeChangeValuesListener, 6 | ThemeValuesGlobal, 7 | } from "../types"; 8 | import { extensionPort } from "../util/comlink"; 9 | 10 | /** 11 | * Returns all metadata on the current theme including syntax highlighting, description, HSL, token values, and more. 12 | */ 13 | export async function getCurrentTheme() { 14 | return await extensionPort.getCurrentTheme(); 15 | } 16 | 17 | /** 18 | * Returns the current theme's global token values. 19 | */ 20 | export async function getCurrentThemeValues(): Promise { 21 | return await extensionPort.getCurrentThemeValues(); 22 | } 23 | 24 | /** 25 | * Fires the `callback` parameter function with the updated theme when the user's theme changes. 26 | */ 27 | export async function onThemeChange( 28 | callback: OnThemeChangeListener 29 | ): Promise { 30 | return await extensionPort.onThemeChange(proxy(callback)); 31 | } 32 | 33 | /** 34 | * Fires the `callback` parameter function with the updated theme values when the user's theme changes. 35 | */ 36 | export async function onThemeChangeValues( 37 | callback: OnThemeChangeValuesListener 38 | ): Promise { 39 | return await extensionPort.onThemeChangeValues(proxy(callback)); 40 | } 41 | -------------------------------------------------------------------------------- /modules/extensions/src/auth/base64.ts: -------------------------------------------------------------------------------- 1 | export const decoder = new TextDecoder(); 2 | 3 | export const decodeBrowser = (input: Uint8Array | string) => { 4 | const decodeBase64 = (encoded: string): Uint8Array => { 5 | const binary = atob(encoded); 6 | const bytes = new Uint8Array(binary.length); 7 | for (let i = 0; i < binary.length; i++) { 8 | bytes[i] = binary.charCodeAt(i); 9 | } 10 | return bytes; 11 | }; 12 | 13 | let encoded = input; 14 | if (encoded instanceof Uint8Array) { 15 | encoded = decoder.decode(encoded); 16 | } 17 | encoded = encoded.replace(/-/g, "+").replace(/_/g, "/").replace(/\s/g, ""); 18 | try { 19 | return decodeBase64(encoded); 20 | } catch { 21 | throw new TypeError("The input to be decoded is not correctly encoded."); 22 | } 23 | }; 24 | 25 | export const decodeNode = (input: Uint8Array | string) => { 26 | function normalize(input: string | Uint8Array) { 27 | let encoded = input; 28 | if (encoded instanceof Uint8Array) { 29 | encoded = decoder.decode(encoded); 30 | } 31 | return encoded; 32 | } 33 | 34 | return Buffer.from(normalize(input), "base64"); 35 | }; 36 | 37 | export function decode(input: Uint8Array | string) { 38 | if (typeof process === "undefined") { 39 | // browser 40 | return decodeBrowser(input); 41 | } else { 42 | return decodeNode(input); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /modules/extensions/src/types/exec.ts: -------------------------------------------------------------------------------- 1 | export type OutputStrCallback = (output: string) => void; 2 | 3 | export interface BaseSpawnOptions { 4 | /** The command and arguments, as an array. This does not spawn with a shell */ 5 | args: string[]; 6 | /** any environment variables to add to the execution context */ 7 | env?: Record; 8 | /** whether to keep stdout and standard error outputs separate */ 9 | splitStderr?: boolean; 10 | } 11 | 12 | export interface SplitStderrSpawnOptions extends BaseSpawnOptions { 13 | splitStderr: true; 14 | /* callback that's triggered when stdout is written to */ 15 | onStdOut?: OutputStrCallback; 16 | /* callback that's triggered when stderr is written to */ 17 | onStdErr?: OutputStrCallback; 18 | } 19 | 20 | export interface CombinedStderrSpawnOptions extends BaseSpawnOptions { 21 | splitStderr?: false; 22 | /* callback that's triggered when stdout or stderr are written to */ 23 | onOutput?: (output: string) => void; 24 | } 25 | 26 | export type SpawnOptions = SplitStderrSpawnOptions | CombinedStderrSpawnOptions; 27 | 28 | export interface SpawnResult { 29 | exitCode: number; 30 | error: string | null; 31 | } 32 | 33 | export interface SpawnOutput { 34 | dispose: () => void; 35 | resultPromise: Promise; 36 | } 37 | 38 | export interface ExecResult { 39 | output: string; 40 | exitCode: number; 41 | } 42 | -------------------------------------------------------------------------------- /modules/tester/src/tests/replDb.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { replDb } from "@replit/extensions"; 3 | import { assert, expect } from "chai"; 4 | import { createReplDBKey } from "../utils/tests"; 5 | import { randomString } from "../utils/assertions"; 6 | 7 | const tests: TestObject = { 8 | "list should list all replDB keys": async (log) => { 9 | const { dispose } = await createReplDBKey(); 10 | 11 | // List keys 12 | const res = await replDb.list({ prefix: "" }); 13 | 14 | // Assertions and logging 15 | assert.isObject(res); 16 | if ("keys" in res) { 17 | assert.isArray(res.keys); 18 | res.keys.forEach(log); 19 | } else { 20 | throw new Error("failed to list keys"); 21 | } 22 | 23 | // Cleanup 24 | dispose(); 25 | }, 26 | "set(): update a replDB kv, get(): check, del(): dispose": async () => { 27 | const { keyName, dispose } = await createReplDBKey(); 28 | 29 | const newValue = randomString(); 30 | 31 | await replDb.set({ 32 | key: keyName, 33 | value: newValue, 34 | }); 35 | 36 | const res = await replDb.get({ 37 | key: keyName, 38 | }); 39 | 40 | assert.isString(res); 41 | expect(res).to.equal(newValue); 42 | 43 | dispose(); 44 | }, 45 | }; 46 | 47 | const ReplDBTests: TestNamespace = { 48 | module: "replDb", 49 | tests, 50 | }; 51 | 52 | export default ReplDBTests; 53 | -------------------------------------------------------------------------------- /modules/extensions-react/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { HandshakeStatus, WriteChange } from "@replit/extensions"; 2 | import type * as replit from "@replit/extensions"; 3 | 4 | export interface UseReplitReady { 5 | status: HandshakeStatus.Ready; 6 | error: null; 7 | filePath: string | null; 8 | replit: typeof replit; 9 | } 10 | 11 | export interface UseReplitLoading { 12 | status: HandshakeStatus.Loading; 13 | error: null; 14 | filePath: null; 15 | replit: typeof replit; 16 | } 17 | 18 | export interface UseReplitFailure { 19 | status: HandshakeStatus.Error; 20 | error: Error; 21 | filePath: null; 22 | replit: typeof replit; 23 | } 24 | 25 | export enum UseWatchTextFileStatus { 26 | Error = "error", 27 | Loading = "loading", 28 | Watching = "watching", 29 | Moved = "moved", 30 | Deleted = "deleted", 31 | } 32 | 33 | export interface UseWatchTextFileLoading { 34 | status: UseWatchTextFileStatus.Loading; 35 | content: null; 36 | watchError: null; 37 | writeChange: null; 38 | } 39 | 40 | export interface UseWatchTextFileWatching { 41 | status: UseWatchTextFileStatus.Watching; 42 | content: string; 43 | watchError: null; 44 | writeChange: WriteChange; 45 | } 46 | 47 | export interface UseWatchTextFileErrorLike { 48 | status: 49 | | UseWatchTextFileStatus.Error 50 | | UseWatchTextFileStatus.Moved 51 | | UseWatchTextFileStatus.Deleted; 52 | content: null; 53 | watchError: string | null; 54 | writeChange: null; 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | typescript: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: 18 19 | - uses: pnpm/action-setup@v2 20 | with: 21 | version: 7.29.1 22 | - run: pnpm install 23 | - run: pnpm type:check 24 | format: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: 18 31 | - uses: pnpm/action-setup@v2 32 | with: 33 | version: 7.29.1 34 | - run: pnpm install 35 | - run: pnpm lint:check 36 | test-build: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions/setup-node@v2 41 | with: 42 | node-version: 18 43 | - uses: pnpm/action-setup@v2 44 | with: 45 | version: 7.29.1 46 | - run: pnpm install 47 | - run: pnpm test:build 48 | test-generate-docs: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v2 52 | - uses: actions/setup-node@v2 53 | with: 54 | node-version: 18 55 | - uses: pnpm/action-setup@v2 56 | with: 57 | version: 7.29.1 58 | - run: pnpm install 59 | - run: pnpm -C modules/extensions generate:docs 60 | -------------------------------------------------------------------------------- /modules/tester/src/tests/messages.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { messages } from "@replit/extensions"; 3 | import { assert } from "chai"; 4 | 5 | const tests: TestObject = { 6 | "showConfirm should create a popup and return an ID": async () => { 7 | const res = await messages.showConfirm("Test confirmation"); 8 | 9 | assert.isString(res); 10 | }, 11 | "showError should create a popup and return an ID": async () => { 12 | const res = await messages.showError("Test error"); 13 | 14 | assert.isString(res); 15 | }, 16 | "showNotice should create a popup and return an ID": async () => { 17 | const res = await messages.showNotice("Test notice"); 18 | 19 | assert.isString(res); 20 | }, 21 | "showWarning should create a popup and return an ID": async () => { 22 | const res = await messages.showWarning("Test warning"); 23 | 24 | assert.isString(res); 25 | }, 26 | "hideMessage should close a popup by its ID": async () => { 27 | const res = await messages.showConfirm("This should close"); 28 | 29 | assert.isString(res); 30 | 31 | await messages.hideMessage(res); 32 | }, 33 | "hideAllMessages should close all popups": async () => { 34 | await messages.showConfirm("This should close"); 35 | await messages.showConfirm("This should close as well"); 36 | await messages.showConfirm("This should close like the others"); 37 | 38 | await messages.hideAllMessages(); 39 | }, 40 | }; 41 | 42 | const MessagesTests: TestNamespace = { 43 | module: "messages", 44 | tests, 45 | }; 46 | 47 | export default MessagesTests; 48 | -------------------------------------------------------------------------------- /modules/tester/src/components/TestGroup.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ChevronDown, ChevronRight } from "react-feather"; 3 | import { UnitTest } from "./Test"; 4 | import { Module } from "../types"; 5 | import { useAppState } from "./StateContext"; 6 | 7 | export default function TestGroup({ module }: { module: Module }) { 8 | const [open, setOpen] = useState(true); 9 | const { tests, setTestQueue, setFailedTests, setPassedTests, setTotalTests } = 10 | useAppState(); 11 | 12 | const moduleTests = tests.filter((t) => t.module === module); 13 | 14 | const runModuleTests = () => { 15 | setFailedTests(0); 16 | setPassedTests(0); 17 | setTotalTests(moduleTests.length); 18 | setTestQueue( 19 | moduleTests.map((t) => ({ 20 | key: t.key, 21 | module: t.module, 22 | })) 23 | ); 24 | }; 25 | 26 | return ( 27 |
28 |
29 | 30 | {module} ({moduleTests.length}) 31 | 32 | 33 | 36 | 42 |
43 | 44 | {open ? ( 45 |
46 | {moduleTests.map((t) => ( 47 | 48 | ))} 49 |
50 | ) : null} 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useReplit.ts: -------------------------------------------------------------------------------- 1 | import * as replit from "@replit/extensions"; 2 | import { HandshakeStatus } from "@replit/extensions"; 3 | import { 4 | connectAtom, 5 | statusAtom, 6 | connectionDisposeAtom, 7 | connectionErrorAtom, 8 | } from "../state/connection"; 9 | import { useSet, useValue } from "../state/store"; 10 | import React from "react"; 11 | import { useFilePath } from "../state/filePath"; 12 | import { UseReplitFailure, UseReplitLoading, UseReplitReady } from "../types"; 13 | 14 | type UseReplitValue = UseReplitReady | UseReplitLoading | UseReplitFailure; 15 | 16 | /** 17 | * Returns the handshake status, connection error (if any), filePath, and Replit API wrapper 18 | */ 19 | export function useReplit(): UseReplitValue { 20 | const connect = useSet(connectAtom); 21 | const status = useValue(statusAtom); 22 | const error = useValue(connectionErrorAtom); 23 | const { fetchFilePath, filePath } = useFilePath(); 24 | 25 | React.useEffect(() => { 26 | (async () => { 27 | await connect(); 28 | await fetchFilePath(); 29 | })(); 30 | 31 | // TODO: cleanup 32 | }, [connect, fetchFilePath]); 33 | 34 | if (status === HandshakeStatus.Loading) { 35 | return { 36 | status, 37 | error: null, 38 | filePath: null, 39 | replit, 40 | }; 41 | } 42 | 43 | if (status === HandshakeStatus.Error) { 44 | return { 45 | status, 46 | error: error || new Error("Unknown handshake error"), 47 | filePath: null, 48 | replit, 49 | }; 50 | } 51 | 52 | const res: UseReplitReady = { 53 | filePath, 54 | status, 55 | error: null, 56 | replit, 57 | }; 58 | 59 | return res; 60 | } 61 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useSetThemeCssVariables.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { ThemeValuesGlobal } from "@replit/extensions"; 3 | import useReplitEffect from "./useReplitEffect"; 4 | 5 | const toKebabCase = (str: string) => 6 | str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); 7 | 8 | const buildCssString = (themeValues: ThemeValuesGlobal) => { 9 | return Object.entries(themeValues) 10 | .map(([key, value]) => `--${toKebabCase(key)}: ${value};`) 11 | .join(" "); 12 | }; 13 | 14 | const applyTheme = (css: string) => { 15 | const styleElement = document.createElement("style"); 16 | styleElement.textContent = `:root { ${css} }`; 17 | document.head.append(styleElement); 18 | }; 19 | 20 | /** 21 | * Sets the global tokens of the current user's theme as CSS variables on the :root selector. 22 | */ 23 | export default function useSetThemeCssVariables() { 24 | const [values, setValues] = useState(null); 25 | 26 | useReplitEffect(async ({ themes }) => { 27 | let themeDispose: null | (() => void) = null; 28 | 29 | let dispose = () => { 30 | if (themeDispose) { 31 | themeDispose(); 32 | themeDispose = null; 33 | } 34 | }; 35 | 36 | (async () => { 37 | const themeValues = await themes.getCurrentThemeValues(); 38 | 39 | setValues(themeValues); 40 | 41 | await themes.onThemeChangeValues(setValues); 42 | 43 | themeDispose = await themes.onThemeChangeValues(setValues); 44 | })(); 45 | 46 | return dispose; 47 | }, []); 48 | 49 | useEffect(() => { 50 | if (values) { 51 | const css = buildCssString(values); 52 | applyTheme(css); 53 | } 54 | }, [values]); 55 | 56 | return values; 57 | } 58 | -------------------------------------------------------------------------------- /modules/tester/public/extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Extensions API Tester", 3 | "description": "Runs all the available Replit Extension APIs, confirming that each API works.", 4 | "version": "0", 5 | "tags": ["extensions", "replit", "tester"], 6 | "website": "https://github.com/replit/extensions", 7 | "coverImages": [ 8 | 9 | { 10 | path: "./cover.png", 11 | label: "preview of extension" 12 | } 13 | 14 | ], 15 | "icon": "./icon.png", 16 | "tools": [ 17 | { 18 | "name": "Extensions API Tester", 19 | "handler": "/", 20 | "icon": "./icon.png" 21 | } 22 | ], 23 | "scopes": [ 24 | { 25 | "name": "read", 26 | "reason": "Required to run and test the `fs` API module." 27 | }, 28 | { 29 | "name": "write-exec", 30 | "reason": "Required to run and test the `fs` and `exec` API modules." 31 | }, 32 | { 33 | "name": "repldb:read", 34 | "reason": "Required to run and test the `replDb` API module." 35 | }, 36 | { 37 | "name": "repldb:write", 38 | "reason": "Required to run and test the `replDb` API module." 39 | }, 40 | { 41 | "name": "experimental-api", 42 | "reason": "Required to run and test experimental APIs" 43 | } 44 | ], 45 | longDescription: "The Extensions API Tester runs all of the available Extension APIs including experimental ones. If a required test fails, that usually means we broke something either in the Extensions client or on Replit's side.\n\nIf you are contributing to the [main repository](https://github.com/replit/extensions), all required tests need to pass before we merge your Pull Request.\n\nIf something is broken, please let us know by making a post on the [Ask Forum](https://ask.replit.com/c/extensions)" 46 | } 47 | -------------------------------------------------------------------------------- /modules/tester/src/tests/actionRequired.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { themes, session } from "@replit/extensions"; 3 | import { assert } from "chai"; 4 | 5 | const tests: TestObject = { 6 | "onActiveFileChange should watch the current active file": async (log) => { 7 | log("⚠️ Please focus three different files for this test to finish ⚠️"); 8 | 9 | let fileVisits = 0; 10 | 11 | await new Promise((resolve) => { 12 | const dispose = session.onActiveFileChange((file) => { 13 | if (typeof file === "string") { 14 | log(file + ` (${fileVisits}/3)`); 15 | fileVisits++; 16 | if (fileVisits >= 3) { 17 | dispose(); 18 | resolve(); 19 | } 20 | } 21 | }); 22 | }); 23 | }, 24 | "onThemeChange fires when the theme changes": async (log) => { 25 | log("⚠️ Please change your theme to run this test ⚠️"); 26 | 27 | await new Promise(async (resolve) => { 28 | const dispose = await themes.onThemeChange(() => { 29 | dispose(); 30 | resolve(); 31 | }); 32 | }); 33 | }, 34 | "onThemeChangeValues fires when the theme changes": async (log) => { 35 | log("⚠️ Please change your theme to run this test ⚠️"); 36 | 37 | await new Promise(async (resolve) => { 38 | const dispose = await themes.onThemeChangeValues((values) => { 39 | assert.isObject(values); 40 | assert.isTrue( 41 | Object.values(values).every((t) => typeof t === "string") 42 | ); 43 | 44 | dispose(); 45 | resolve(); 46 | }); 47 | }); 48 | }, 49 | }; 50 | 51 | const ActionRequiredTests: TestNamespace = { 52 | module: "actionRequired", 53 | tests, 54 | }; 55 | 56 | export default ActionRequiredTests; 57 | -------------------------------------------------------------------------------- /modules/tester/src/components/StateContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react"; 2 | import { Test, AppState } from "../types"; 3 | import UnitTests from "../tests"; 4 | 5 | const mappedTests = Object.values(UnitTests) 6 | .map(({ module, tests }) => { 7 | return Object.keys(tests).map( 8 | (key) => 9 | ({ 10 | module, 11 | key, 12 | status: "idle", 13 | shouldRun: false, 14 | } as Test) 15 | ); 16 | }) 17 | .flat(1) 18 | .sort((a, b) => a.module.localeCompare(b.module)); 19 | 20 | export const StateContext = createContext(null); 21 | 22 | export const useAppState = () => { 23 | const state = React.useContext(StateContext); 24 | 25 | if (!state) { 26 | throw new Error("useAppState must be used within a StateProvider"); 27 | } 28 | 29 | return state; 30 | }; 31 | 32 | export default function AppStateProvider({ 33 | children, 34 | }: { 35 | children: React.ReactNode; 36 | }) { 37 | const [testQueue, setTestQueue] = React.useState< 38 | Array> 39 | >([]); 40 | const [logs, setLogs] = React.useState>([]); 41 | const [passedTests, setPassedTests] = React.useState(null); 42 | const [failedTests, setFailedTests] = React.useState(null); 43 | const [totalTests, setTotalTests] = React.useState(null); 44 | 45 | return ( 46 | 61 | {children} 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /modules/extensions/src/api/commands.ts: -------------------------------------------------------------------------------- 1 | import { extensionPort, proxy } from "../util/comlink"; 2 | import { 3 | CreateCommand, 4 | CommandProxy, 5 | ContributionType, 6 | Command, 7 | CommandFnArgs, 8 | CommandSymbol, 9 | CommandArgs, 10 | isCommandProxy, 11 | } from "../commands"; 12 | 13 | export interface AddCommandArgs { 14 | /** 15 | * The command's unique identifier. This is used to identify the command in Replit's command system 16 | */ 17 | id: string; 18 | 19 | /** 20 | * The surfaces that this command should appear in. This is an array of strings 21 | */ 22 | contributions: Array; 23 | 24 | /** 25 | * A Command, or, a function that returns a Command. 26 | */ 27 | command: CommandProxy | CreateCommand; 28 | } 29 | 30 | /** 31 | * Adds a command to the command system. 32 | * 33 | * @param id The command's unique identifier. This is used to identify the command in Replit's command system 34 | * @param contributions The surfaces that this command should appear in. This is an array of strings 35 | * @param command A Command, or, a function that returns a Command. 36 | */ 37 | export function add({ id, contributions, command }: AddCommandArgs) { 38 | if (typeof command === "function") { 39 | let createCommand = proxy(async (cmdFnArgs: CommandFnArgs) => { 40 | const cmd = await command(cmdFnArgs); 41 | 42 | if (!cmd) { 43 | return null; 44 | } 45 | 46 | return isCommandProxy(cmd) ? cmd : Command(cmd); 47 | }); 48 | 49 | extensionPort.commands.registerCreateCommand( 50 | { commandId: id, contributions }, 51 | createCommand 52 | ); 53 | } else { 54 | let createCommand = proxy(async () => { 55 | return isCommandProxy(command) ? command : Command(command); 56 | }); 57 | 58 | extensionPort.commands.registerCreateCommand( 59 | { commandId: id, contributions }, 60 | createCommand 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /modules/extensions-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@replit/extensions-react", 3 | "version": "0.6.0", 4 | "description": "The React Extensions API Client comes with a set of hooks and components that combine to make a blazingly fast and seamless developer experience.", 5 | "exports": { 6 | "./package.json": "./package.json", 7 | ".": { 8 | "types": "./src/index.ts", 9 | "import": { 10 | "default": "./src/index.ts" 11 | } 12 | } 13 | }, 14 | "types": "./src/index.ts", 15 | "publishConfig": { 16 | "types": "./dist/index.d.ts", 17 | "exports": { 18 | "./package.json": "./package.json", 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "import": { 22 | "default": "./dist/index.js" 23 | }, 24 | "default": "./dist/index.cjs", 25 | "require": "./dist/index.js" 26 | } 27 | } 28 | }, 29 | "type": "module", 30 | "unpkg": "dist/index.global.js", 31 | "scripts": { 32 | "build": "tsup src/index.ts --sourcemap --dts --format esm,cjs,iife --global-name replit", 33 | "clean": "rm -rf dist", 34 | "lint": "npx prettier --write src/*", 35 | "lint:check": "npx prettier -l src/*", 36 | "type:check": "tsc --noEmit", 37 | "test:build": "jest buildTests" 38 | }, 39 | "files": [ 40 | "dist/*" 41 | ], 42 | "keywords": [ 43 | "replit", 44 | "extensions", 45 | "react", 46 | "api-client" 47 | ], 48 | "author": "", 49 | "license": "MIT", 50 | "dependencies": { 51 | "@types/react": "^18.2.0", 52 | "jotai": "^2.4.2" 53 | }, 54 | "devDependencies": { 55 | "@replit/extensions": "workspace:*", 56 | "esbuild": "^0.15.18", 57 | "prettier": "^2.7.1", 58 | "react": "^18.2.0", 59 | "react-dom": "^18.2.0", 60 | "tsup": "^6.6.3", 61 | "typescript": "^4.9.3" 62 | }, 63 | "peerDependencies": { 64 | "@replit/extensions": ">=1.x", 65 | "react": ">=17.0.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /modules/extensions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@replit/extensions", 3 | "version": "1.10.0", 4 | "description": "The Replit Extensions client is a module that allows you to easily interact with the Workspace.", 5 | "types": "./src/index.ts", 6 | "exports": { 7 | "./package.json": "./package.json", 8 | ".": { 9 | "types": "./src/index.ts", 10 | "import": { 11 | "default": "./src/index.ts" 12 | } 13 | } 14 | }, 15 | "publishConfig": { 16 | "exports": { 17 | "./package.json": "./package.json", 18 | ".": { 19 | "types": "./dist/index.d.ts", 20 | "import": { 21 | "default": "./dist/index.js" 22 | }, 23 | "default": "./dist/index.cjs", 24 | "require": "./dist/index.js" 25 | } 26 | }, 27 | "types": "./dist/index.d.ts" 28 | }, 29 | "type": "module", 30 | "unpkg": "dist/index.global.js", 31 | "scripts": { 32 | "build": "tsup src/index.ts --sourcemap --dts --platform browser --format esm,cjs,iife --global-name replit", 33 | "test:build": "jest buildTests", 34 | "lint": "npx prettier --write src/*", 35 | "lint:check": "npx prettier -l src/*", 36 | "type:check": "tsc --noEmit", 37 | "clean": "rm -rf dist", 38 | "generate:docs": "npx typedoc src/index.ts --json docs/main.json --plugin ./util/signature-plugin/index.cjs" 39 | }, 40 | "files": [ 41 | "dist/*" 42 | ], 43 | "keywords": [ 44 | "replit", 45 | "extensions", 46 | "api-client" 47 | ], 48 | "author": "", 49 | "license": "MIT", 50 | "dependencies": { 51 | "@codemirror/state": "^6.2.0", 52 | "@noble/curves": "^1.0.0", 53 | "@root/asn1": "^1.0.0", 54 | "b64u-lite": "^1.1.0", 55 | "comlink": "^4.3.1" 56 | }, 57 | "devDependencies": { 58 | "@types/root__asn1": "^1.0.2", 59 | "esbuild": "^0.15.18", 60 | "prettier": "^2.7.1", 61 | "tsup": "^6.6.3", 62 | "typedoc": "^0.24.8", 63 | "typescript": "^4.9.3" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /modules/tester/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from "./StateContext"; 2 | 3 | export default function Header() { 4 | const { 5 | setTestQueue, 6 | tests, 7 | setFailedTests, 8 | setPassedTests, 9 | setTotalTests, 10 | totalTests, 11 | passedTests, 12 | failedTests, 13 | } = useAppState(); 14 | 15 | return ( 16 |
17 |

Extension Tester

18 | 19 |
20 | 21 | {`Passed: `} 22 | {passedTests} 23 | {`, Failed: `} 24 | {failedTests} 25 | {`, Total: `} 26 | {totalTests} 27 | 28 |
29 | 30 |
31 | 50 | 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /modules/extensions/src/api/messages.ts: -------------------------------------------------------------------------------- 1 | import { extensionPort } from "../util/comlink"; 2 | 3 | /** 4 | * Shows a confirmation toast message within the Replit workspace for `length` milliseconds. Returns the ID of the message as a UUID 5 | */ 6 | export const showConfirm = async (str: string, length: number = 4000) => { 7 | if (typeof str !== "string") { 8 | throw new Error("Messages must be strings"); 9 | } 10 | 11 | return extensionPort.showConfirm(str, length); 12 | }; 13 | 14 | /** 15 | * Shows an error toast message within the Replit workspace for `length` milliseconds. Returns the ID of the message as a UUID 16 | */ 17 | export const showError = async (str: string, length: number = 4000) => { 18 | if (typeof str !== "string") { 19 | throw new Error("Messages must be strings"); 20 | } 21 | 22 | return extensionPort.showError(str, length); 23 | }; 24 | 25 | /** 26 | * Shows a notice toast message within the Replit workspace for `length` milliseconds. Returns the ID of the message as a UUID 27 | */ 28 | export const showNotice = async (str: string, length: number = 4000) => { 29 | if (typeof str !== "string") { 30 | throw new Error("Messages must be strings"); 31 | } 32 | 33 | return extensionPort.showNotice(str, length); 34 | }; 35 | 36 | /** 37 | * Shows a warning toast message within the Replit workspace for `length` milliseconds. Returns the ID of the message as a UUID 38 | */ 39 | export const showWarning = async (str: string, length: number = 4000) => { 40 | if (typeof str !== "string") { 41 | throw new Error("Messages must be strings"); 42 | } 43 | 44 | return extensionPort.showWarning(str, length); 45 | }; 46 | 47 | /** 48 | * Hides a message by its IDs 49 | */ 50 | export const hideMessage = async (id: string) => { 51 | return extensionPort.hideMessage(id); 52 | }; 53 | 54 | /** 55 | * Hides all toast messages visible on the screens 56 | */ 57 | export const hideAllMessages = async () => { 58 | return extensionPort.hideAllMessages(); 59 | }; 60 | -------------------------------------------------------------------------------- /modules/extensions/buildTests/build.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import fs from "fs"; 5 | import { version } from "../package.json"; 6 | 7 | declare global { 8 | interface Window { 9 | replit: any; 10 | } 11 | } 12 | 13 | describe("dist/index.global.js (IIFE)", () => { 14 | test("exists", () => { 15 | expect(fs.existsSync("./dist/index.global.js")).toBe(true); 16 | }); 17 | test("sourcemap file exists", () => { 18 | expect(fs.existsSync("./dist/index.global.js.map")).toBe(true); 19 | }); 20 | test("evaluates to produce `replit` object on window", async () => { 21 | let replitPromise = new Promise((resolve, reject) => { 22 | const code = fs.readFileSync("./dist/index.global.js", "utf8"); 23 | 24 | const scriptTag = document.createElement("script"); 25 | scriptTag.type = "text/javascript"; 26 | scriptTag.text = code; 27 | scriptTag.onload = () => { 28 | resolve(window.replit); 29 | }; 30 | scriptTag.onerror = () => { 31 | reject(new Error("Failed to load script")); 32 | }; 33 | document.body.appendChild(scriptTag); 34 | }); 35 | 36 | expect(replitPromise).resolves.toBeDefined(); 37 | 38 | const replit = await replitPromise; 39 | expect(replit).toBeDefined(); 40 | expect((replit as any).version).toEqual(version); 41 | }); 42 | }); 43 | 44 | describe("dist/index.cjs (CommonJS)", () => { 45 | test("exists", () => { 46 | expect(fs.existsSync("./dist/index.cjs")).toBe(true); 47 | }); 48 | test("sourcemap file exists", () => { 49 | expect(fs.existsSync("./dist/index.cjs.map")).toBe(true); 50 | }); 51 | }); 52 | 53 | describe("dist/index.js (ES Module)", () => { 54 | test("exists", () => { 55 | expect(fs.existsSync("./dist/index.js")).toBe(true); 56 | }); 57 | test("sourcemap file exists", () => { 58 | expect(fs.existsSync("./dist/index.js.map")).toBe(true); 59 | }); 60 | }); 61 | 62 | describe("dist/index.d.ts (TypeScript defs)", () => { 63 | test("exists", () => { 64 | expect(fs.existsSync("./dist/index.d.ts")).toBe(true); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "replit-extensions-client", 3 | "description": "Replit Extensions Monorepo (All Clients)", 4 | "scripts": { 5 | "tester": "cd modules/tester && pnpm dev", 6 | "dev": "cd modules/dev && pnpm dev", 7 | "lint": "turbo run lint", 8 | "lint:check": "turbo run lint:check", 9 | "type:check": "turbo run type:check", 10 | "test:build": "turbo run test:build", 11 | "build": "turbo run build", 12 | "clean": "turbo run clean", 13 | "publish:extensions": "turbo run lint:check type:check build && cd modules/extensions && pnpm publish", 14 | "publish:react": "turbo run lint:check type:check build && cd modules/extensions-react && pnpm publish", 15 | "publish:extensions:beta": "turbo run lint:check type:check build && cd modules/extensions && pnpm publish --tag beta", 16 | "publish:react:beta": "turbo run lint:check type:check build && cd modules/extensions-react && pnpm publish --tag beta", 17 | "test:extensions": "cd modules/extensions && turbo run build test:build --log-prefix=none", 18 | "test:react": "cd modules/extensions-react && turbo run build test:build --log-prefix=none", 19 | "test": "pnpm test:extensions && pnpm test:react" 20 | }, 21 | "keywords": [ 22 | "extensions", 23 | "api", 24 | "client", 25 | "replit" 26 | ], 27 | "author": "Replit", 28 | "license": "MIT", 29 | "engines": { 30 | "node": ">=18", 31 | "pnpm": ">=6" 32 | }, 33 | "devDependencies": { 34 | "@changesets/cli": "^2.26.2", 35 | "@replit/extensions": "workspace:*", 36 | "@testing-library/jest-dom": "^5.17.0", 37 | "@types/jest": "^29.5.5", 38 | "@types/node": "^20.6.1", 39 | "@types/react": "^18.2.0", 40 | "@vitejs/plugin-react": "^4.0.4", 41 | "esbuild": "^0.15.18", 42 | "esbuild-jest": "^0.5.0", 43 | "jest": "^29.7.0", 44 | "jest-environment-jsdom": "^29.7.0", 45 | "prettier": "^2.8.8", 46 | "react": "^18.2.0", 47 | "react-dom": "^18.2.0", 48 | "tsup": "^6.7.0", 49 | "turbo": "^1.10.14", 50 | "typedoc": "^0.24.8", 51 | "typescript": "^4.9.5", 52 | "vite": "^4.4.9" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /modules/tester/src/tests/data.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { data } from "@replit/extensions"; 3 | import { assert, expect } from "chai"; 4 | 5 | const tests: TestObject = { 6 | "currentUser gets the current user": async (log) => { 7 | const res = await data.currentUser(); 8 | 9 | assert.isObject(res.user); 10 | assert.isNumber(res.user.id); 11 | 12 | log("Current User: " + res.user.username); 13 | }, 14 | "userById fetches a Replit user by their ID": async () => { 15 | const res = await data.userById({ 16 | id: 1, 17 | }); 18 | 19 | assert.isObject(res.user); 20 | assert.isString(res.user.username); 21 | }, 22 | "userByUsername fetches a Replit user by their username": async () => { 23 | const res = await data.userByUsername({ 24 | username: "friend", // friend is the autogenerated user on local, and exists in prod as well 25 | }); 26 | 27 | assert.isObject(res.userByUsername); 28 | assert.isString(res.userByUsername.username); 29 | }, 30 | "currentRepl gets fetches the current Repl": async (log) => { 31 | const res = await data.currentRepl({ 32 | includeOwner: true, 33 | }); 34 | 35 | assert.isObject(res.repl); 36 | assert.isString(res.repl.id); 37 | 38 | log("Repl: " + res.repl.title + " by @" + res.repl.owner?.username); 39 | }, 40 | "replById fetches a Repl by its ID": async () => { 41 | const currentRepl = await data.currentRepl(); 42 | const id = currentRepl.repl.id; 43 | 44 | const res = await data.replById({ id }); 45 | 46 | assert.isObject(res.repl); 47 | assert.isString(res.repl.id); 48 | assert.isString(res.repl.title); 49 | }, 50 | "replByUrl fetches a Repl by its URL": async () => { 51 | const currentRepl = await data.currentRepl(); 52 | const res = await data.replByUrl({ url: currentRepl.repl.url }); 53 | 54 | assert.isObject(res.repl); 55 | assert.isString(res.repl.id); 56 | assert.isString(res.repl.title); 57 | }, 58 | }; 59 | 60 | const DataTests: TestNamespace = { 61 | module: "data", 62 | tests, 63 | }; 64 | 65 | export default DataTests; 66 | -------------------------------------------------------------------------------- /modules/extensions/src/api/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReplDataInclusion, 3 | UserDataInclusion, 4 | CurrentUserDataInclusion, 5 | } from "../types"; 6 | import { extensionPort } from "../util/comlink"; 7 | 8 | /** 9 | * Fetches the current user via graphql 10 | */ 11 | export async function currentUser(args: CurrentUserDataInclusion = {}) { 12 | return await extensionPort.currentUser(args); 13 | } 14 | 15 | /** 16 | * Fetches a user by their id via graphql 17 | */ 18 | export async function userById(args: { id: number } & UserDataInclusion) { 19 | if (typeof args.id !== "number") { 20 | throw new Error( 21 | `Query parameter "id" must be a number. Found type ${typeof args.id} instead.` 22 | ); 23 | } 24 | 25 | return await extensionPort.userById(args); 26 | } 27 | 28 | /** 29 | * Fetches a user by their username via graphql 30 | */ 31 | export async function userByUsername( 32 | args: { username: string } & UserDataInclusion 33 | ) { 34 | if (typeof args.username !== "string") { 35 | throw new Error( 36 | `Query parameter "username" must be a string. Found type ${typeof args.username} instead.` 37 | ); 38 | } 39 | 40 | return await extensionPort.userByUsername(args); 41 | } 42 | 43 | /** 44 | * Fetches the current Repl via graphql 45 | */ 46 | export async function currentRepl(args: ReplDataInclusion = {}) { 47 | return await extensionPort.currentRepl(args); 48 | } 49 | 50 | /** 51 | * Fetches a Repl by its ID via graphql 52 | */ 53 | export async function replById(args: { id: string } & ReplDataInclusion) { 54 | if (typeof args.id !== "string") { 55 | throw new Error( 56 | `Query parameter "id" must be a string. Found type ${typeof args.id} instead.` 57 | ); 58 | } 59 | 60 | return await extensionPort.replById(args); 61 | } 62 | 63 | /** 64 | * Fetches a Repl by its URL via graphql 65 | */ 66 | export async function replByUrl(args: { url: string } & ReplDataInclusion) { 67 | if (typeof args.url !== "string") { 68 | throw new Error( 69 | `Query parameter "url" must be a string. Found type ${typeof args.url} instead.` 70 | ); 71 | } 72 | 73 | return await extensionPort.replByUrl(args); 74 | } 75 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to `@replit/extensions` 2 | 3 | We appreciate your interest in contributing to our project! As a team, we believe in the power of community-driven development and the potential of collaborative open-source projects to create and foster innovation. 4 | 5 | ## Getting Started 6 | 7 | 1. Fork the [repository](https://github.com/replit/extensions) by clicking the "Fork" button in the top-right corner of the page. 8 | 2. Clone your forked repository to your local machine using `git clone https://github.com/replit/extensions.git`. 9 | 3. Navigate into the repository with `cd extensions`. 10 | 4. Create a new branch for your feature, bug fix, or enhancement: `git checkout -b your-branch-name`. 11 | 5. Make your changes to the codebase. 12 | 6. Add and commit your changes using clear and concise commit messages: `git add .`, `git commit -m "Description of your changes"`. 13 | 7. Push your changes to your forked repository: `git push -u origin your-branch-name`. 14 | 8. Create a pull request from your forked repository to the original repository. Provide a descriptive title and comments explaining your proposed changes. 15 | 16 | ## Developer Guide 17 | 18 | 1. Import [this repository](https://replit.com/github/replit/extensions) onto Replit. Make sure you select Typescript as the language. If you make your own fork to create a Pull Request, import that onto Replit instead. 19 | 2. Install packages by running `pnpm install` in the shell 20 | 3. Run `git reset --hard origin/main` in the shell 21 | 4. Run the Repl 22 | 5. Open the Extension Devtools and press "Preview" on the Extensions API Tester 23 | 24 | ## Checklist 25 | 26 | 1. Ensure that your code does not have any syntax or compiler errors. You can check this by running `pnpm type:check` in the shell. 27 | 2. Make sure everything is formatted properly with `pnpm lint`. 28 | 3. Write a test plan showing how we can test that your new implementation works 29 | 4. Make sure tests pass as expected with the Extensions API Tester. If you introduce a new API method, make sure you write a test for it. 30 | 31 | ## Requesting Features or Enhancements 32 | 33 | We encourage suggestions to improve our project. If you have an idea for a new feature or enhancement, please create a post on the [Ask Forum](https://ask.replit.com) in the [relevant category](https://ask.replit.com/c/extensions). 34 | -------------------------------------------------------------------------------- /modules/extensions/src/index.ts: -------------------------------------------------------------------------------- 1 | import { HandshakeStatus, ReplitInitArgs, ReplitInitOutput } from "./types"; 2 | import { extensionPort, proxy } from "./util/comlink"; 3 | import { getHandshakeStatus, setHandshakeStatus } from "./util/handshake"; 4 | export * from "./api"; 5 | export { extensionPort, proxy }; 6 | export * from "./types"; 7 | export * from "./commands"; 8 | import * as replit from "."; 9 | 10 | import { version } from "../package.json"; 11 | import { patchConsole } from "./util/patchConsole"; 12 | 13 | export { version }; 14 | 15 | function promiseWithTimeout(promise: Promise, timeout: number) { 16 | return Promise.race([ 17 | promise, 18 | new Promise((_resolve, reject) => 19 | setTimeout(() => reject(new Error("timeout")), timeout) 20 | ), 21 | ]); 22 | } 23 | 24 | async function windowIsReady() { 25 | return new Promise((resolve) => { 26 | if (document.readyState === "complete") { 27 | resolve(); 28 | return; 29 | } 30 | 31 | const loadHandler = () => { 32 | resolve(); 33 | window.removeEventListener("load", loadHandler); 34 | }; 35 | 36 | window.addEventListener("load", loadHandler); 37 | }); 38 | } 39 | 40 | export async function init(args?: ReplitInitArgs): Promise { 41 | if (extensionPort === null) { 42 | throw new Error("Extension must be initialized in a browser context"); 43 | } 44 | 45 | const onExtensionClick = () => { 46 | extensionPort.activatePane(); 47 | }; 48 | 49 | const windDown = () => { 50 | window.document.removeEventListener("click", onExtensionClick); 51 | }; 52 | 53 | try { 54 | if (window) { 55 | await windowIsReady(); 56 | } 57 | 58 | await promiseWithTimeout( 59 | extensionPort.handshake({ 60 | clientName: "@replit/extensions", 61 | clientVersion: version, 62 | }), 63 | args?.timeout || 2000 64 | ); 65 | 66 | patchConsole(); 67 | 68 | setHandshakeStatus(HandshakeStatus.Ready); 69 | 70 | if (window) { 71 | window.document.addEventListener("click", onExtensionClick); 72 | } 73 | } catch (e) { 74 | setHandshakeStatus(HandshakeStatus.Error); 75 | console.error(e); 76 | windDown(); 77 | throw e; 78 | } 79 | 80 | return { 81 | dispose: windDown, 82 | status: getHandshakeStatus(), 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /modules/extensions/src/api/experimental/auth.ts: -------------------------------------------------------------------------------- 1 | import { extensionPort } from "../../util/comlink"; 2 | import { AuthenticateResult } from "../../types"; 3 | import { verifyJWTAndDecode, decodeProtectedHeader } from "../../auth/verify"; 4 | 5 | /** 6 | * Returns a unique JWT token that can be used to verify that an extension has been loaded on Replit by a particular user 7 | */ 8 | export async function getAuthToken() { 9 | return extensionPort.experimental.auth.getAuthToken(); 10 | } 11 | 12 | /** 13 | * Verifies a provided JWT token and returns the decoded token. 14 | */ 15 | export async function verifyAuthToken( 16 | token: string 17 | ): Promise<{ payload: any; protectedHeader: any }> { 18 | const tokenHeaders = decodeProtectedHeader(token); 19 | 20 | if (tokenHeaders.typ !== "JWT") { 21 | throw new Error("Expected typ: JWT"); 22 | } 23 | 24 | if (tokenHeaders.alg !== "EdDSA") { 25 | throw new Error("Expected alg: EdDSA"); 26 | } 27 | 28 | if (!tokenHeaders.kid) { 29 | throw new Error("Expected `kid` to be defined"); 30 | } 31 | 32 | const res = await fetch( 33 | `https://replit.com/data/extensions/publicKey/${tokenHeaders.kid}` 34 | ); 35 | 36 | const { ok, value: publicKey } = await res.json(); 37 | 38 | if (!ok) { 39 | throw new Error("Extension Auth: Failed to fetch public key"); 40 | } 41 | 42 | try { 43 | const decodedToken = await verifyJWTAndDecode(token, publicKey); 44 | 45 | return decodedToken; 46 | } catch (e) { 47 | throw new Error("Extension Auth: Failed to verify token"); 48 | } 49 | } 50 | 51 | /** 52 | * Performs authentication and returns the user and installation information 53 | */ 54 | export async function authenticate(): Promise { 55 | const token = await getAuthToken(); 56 | const decodedToken = await verifyAuthToken(token); 57 | 58 | if ( 59 | typeof decodedToken.payload.userId !== "number" || 60 | typeof decodedToken.payload.installationId !== "string" || 61 | typeof decodedToken.payload.extensionId !== "string" 62 | ) { 63 | throw new Error("Failed to authenticate"); 64 | } 65 | 66 | return { 67 | user: { 68 | id: decodedToken.payload.userId, 69 | }, 70 | installation: { 71 | id: decodedToken.payload.installationId, 72 | extensionId: decodedToken.payload.extensionId, 73 | }, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /modules/extensions-react/README.md: -------------------------------------------------------------------------------- 1 | # Replit Extensions API Client (React) 2 | 3 | The React Extensions API Client comes with a set of hooks and components that combine to make a blazingly fast and seamless developer experience. 4 | 5 | - NPM Packages 6 | - https://www.npmjs.com/package/@replit/extensions 7 | - https://www.npmjs.com/package/@replit/extensions-react 8 | - [Repository](https://github.com/replit/extensions) 9 | - [Documentation](https://docs.replit.com/extensions) 10 | - [Resources](https://docs.replit.com/extensions/resources) 11 | - [API Modules](https://docs.replit.com/extensions/category/api-reference) 12 | - [React Client](https://docs.replit.com/extensions/category/react) 13 | - [Discourse Category](https://ask.replit.com/c/extensions) 14 | - [React Extension Template](https://replit.com/@replit/React-Extension?v=1) 15 | - [HTML/CSS/JS Extension Template](https://replit.com/@replit/HTMLCSSJS-Extension?v=1) 16 | 17 | ## Installation 18 | 19 | ``` 20 | npm install @replit/extensions-react 21 | yarn add @replit/extensions-react 22 | pnpm add @replit/extensions-react 23 | ``` 24 | 25 | ## Usage 26 | 27 | Fork the [React Extension Template](https://replit.com/@replit/React-Extension?v=1) to get started. Alternatively, you can start from scratch by wrapping your application with the `HandshakeProvider` component imported from `@replit/extensions-react`. 28 | 29 | ```tsx 30 | import { HandshakeProvider } from "@replit/extensions-react"; 31 | import { createRoot } from "react-dom/client"; 32 | import App from "./App"; 33 | 34 | createRoot(document.getElementById("root")).render( 35 | 36 | 37 | 38 | ); 39 | ``` 40 | 41 | In the `App` function, check the handshake status with the `useReplit` hook. 42 | 43 | ```tsx 44 | import { useReplit } from "@replit/extensions-react"; 45 | 46 | function App() { 47 | const { status, error, replit } = useReplit(); 48 | 49 | if (status === "loading") { 50 | return
Loading...
; 51 | } 52 | 53 | if (status === "error") { 54 | return
An error occurred: {error?.message}
; 55 | } 56 | 57 | return
Extension is Ready!
; 58 | } 59 | ``` 60 | 61 | ## Help 62 | 63 | If you don't understand something in the documentation, have found a bug, or would like to request a feature, you can get help on the [Ask Forum](https://ask.replit.com/c/extensions). 64 | -------------------------------------------------------------------------------- /modules/extensions/src/api/exec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecResult, 3 | SpawnOptions, 4 | SpawnOutput, 5 | SpawnResult, 6 | } from "../types/exec"; 7 | import { extensionPort, proxy } from "../util/comlink"; 8 | 9 | /** 10 | * Spawns a command, with given arguments and environment variables. Takes in callbacks, 11 | * and returns an object containing a promise that resolves when the command exits, and 12 | * a dispose function to kill the process 13 | */ 14 | function spawn(options: SpawnOptions): SpawnOutput { 15 | let execResult = extensionPort.exec( 16 | proxy({ 17 | args: options.args, 18 | env: options.env || {}, 19 | splitStderr: options.splitStderr ?? false, 20 | onOutput: (output: string) => { 21 | if (options.splitStderr) { 22 | options.onStdOut?.(output); 23 | } else { 24 | options.onOutput?.(output); 25 | } 26 | }, 27 | onStdErr: (stderr: string) => { 28 | if (options.splitStderr) { 29 | options.onStdErr?.(stderr); 30 | } else { 31 | options.onOutput?.(stderr); 32 | } 33 | }, 34 | onError: (err: string) => { 35 | throw err; 36 | }, 37 | }) 38 | ); 39 | 40 | let dispose = async () => { 41 | (await execResult).dispose(); 42 | }; 43 | 44 | const resultPromise = new Promise(async (resolve) => { 45 | const { exitCode, error } = await (await execResult).promise; 46 | 47 | resolve({ 48 | error, 49 | exitCode: exitCode ?? 0, 50 | }); 51 | }); 52 | 53 | return { 54 | resultPromise, 55 | dispose, 56 | }; 57 | } 58 | 59 | /** 60 | * Executes a command in the shell, with given arguments and environment variables 61 | */ 62 | async function exec( 63 | command: string, 64 | options: { 65 | env?: Record; 66 | } = {} 67 | ): Promise { 68 | let output = ""; 69 | const { resultPromise } = spawn({ 70 | args: ["bash", "-c", command], 71 | env: options.env ?? {}, 72 | splitStderr: false, 73 | onOutput: (newOutput: string) => { 74 | output += newOutput; 75 | }, 76 | }); 77 | 78 | const result = await resultPromise; 79 | 80 | if (result.error) { 81 | throw new Error(result.error); 82 | } 83 | 84 | return { 85 | output, 86 | exitCode: result.exitCode, 87 | }; 88 | } 89 | 90 | export { spawn, exec }; 91 | -------------------------------------------------------------------------------- /modules/extensions/src/api/debug.ts: -------------------------------------------------------------------------------- 1 | import { extensionPort } from "../util/comlink"; 2 | 3 | export type Primitive = string | boolean | number | null | undefined | never; 4 | 5 | export interface ObjectType { 6 | [n: string | number]: Serializable; 7 | } 8 | 9 | export interface NumericIndexType { 10 | [n: number]: Serializable; 11 | } 12 | 13 | export type Serializable = ObjectType | Primitive | NumericIndexType; 14 | 15 | export type Data = Record; 16 | 17 | function isSerializable(thing: any): thing is Serializable { 18 | if (["string", "number", "boolean", "undefined"].includes(typeof thing)) { 19 | return true; 20 | } 21 | 22 | if (thing === null) { 23 | return true; 24 | } 25 | 26 | return false; 27 | } 28 | 29 | /** 30 | * Logs information to the Extension Devtools 31 | */ 32 | async function info(message: string, data?: Data) { 33 | if (!isSerializable(message)) { 34 | // if someone uses console.info / console.log, the wrapper defined in index.ts will log the object to the console. 35 | extensionPort.debug.warn( 36 | "Attempted to log non-serializable message. See your browser devtools to access the logged object." 37 | ); 38 | 39 | return; 40 | } 41 | 42 | return await extensionPort.debug.info(message, data); 43 | } 44 | 45 | /** 46 | * Logs a warning to the extension devtools 47 | */ 48 | async function warn(message: string, data?: Data) { 49 | if (!isSerializable(message)) { 50 | // if someone uses console.warn, the wrapper defined in index.ts will log the object to the console. 51 | extensionPort.debug.warn( 52 | "Attempted to log non-serializable message. See your browser devtools to access the logged object." 53 | ); 54 | 55 | return; 56 | } 57 | 58 | return await extensionPort.debug.warn(message, data); 59 | } 60 | 61 | /** 62 | * Logs an error message to the extension devtools 63 | */ 64 | async function error(message: string, data?: Data) { 65 | if (!isSerializable(message)) { 66 | // if someone uses console.error, the wrapper defined in index.ts will log the object to the console. 67 | extensionPort.debug.warn( 68 | "Attempted to log non-serializable message. See your browser devtools to access the logged object." 69 | ); 70 | 71 | return; 72 | } 73 | 74 | return await extensionPort.debug.error(message, data); 75 | } 76 | 77 | // Log is just an alias for info for now 78 | const log = info; 79 | 80 | export { info, warn, error, log }; 81 | -------------------------------------------------------------------------------- /modules/tester/src/utils/tests.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertPathOrNameValidity, 3 | assertFileContents, 4 | randomString, 5 | } from "./assertions"; 6 | import { fs, replDb } from "@replit/extensions"; 7 | import { assert } from "chai"; 8 | 9 | // Create the test directory if it doesn't exist 10 | export async function createTestDirIfNotExists() { 11 | const res = await fs.readDir("extension_tester"); 12 | 13 | if (!Array.isArray(res.children) || res.error) { 14 | await fs.createDir("extension_tester"); 15 | } 16 | } 17 | 18 | // Create a test file suffixed with a timestamp 19 | export async function createTestFile(name: string, content?: string) { 20 | assertPathOrNameValidity(name); 21 | 22 | // Need the test dir 23 | await createTestDirIfNotExists(); 24 | 25 | // Append a timestamp to the file name 26 | const fileName = name.replace(".", `-${Date.now()}.`); 27 | const fileContent = content || randomString(); 28 | 29 | // Create the file 30 | await fs.writeFile(fileName, fileContent); 31 | 32 | // Assert that the file has been created and exists 33 | await assertFileExists(fileName); 34 | 35 | // Cleanup 36 | const dispose = async () => { 37 | await fs.deleteFile(fileName); 38 | }; 39 | 40 | return { fileName, fileContent, dispose }; 41 | } 42 | 43 | // Makes sure a file exists 44 | export async function assertFileExists(path: string) { 45 | assertPathOrNameValidity(path); 46 | const res = await fs.readFile(path); 47 | assertFileContents(res); 48 | 49 | return { 50 | content: assertFileContents(res), 51 | }; 52 | } 53 | 54 | // Make sure a directory exists 55 | export async function assertDirExists(path: string) { 56 | assertPathOrNameValidity(path); 57 | const res = await fs.readDir(path); 58 | assert.isArray(res.children); 59 | 60 | return { 61 | children: res.children, 62 | }; 63 | } 64 | 65 | // Create a test directory by name 66 | export async function createTestDir(dirName: string) { 67 | assertPathOrNameValidity(dirName); 68 | 69 | await createTestDirIfNotExists(); 70 | 71 | await fs.createDir(dirName); 72 | 73 | // Assert that the directory has been created and exists 74 | await assertDirExists(dirName); 75 | 76 | const dispose = async () => { 77 | await fs.deleteDir(dirName); 78 | }; 79 | 80 | return { dirName, dispose }; 81 | } 82 | 83 | // Create a random replDB key 84 | export async function createReplDBKey(val?: string) { 85 | const keyName = "extension_key_" + Date.now(); 86 | const value = val || randomString(); 87 | 88 | await replDb.set({ 89 | key: keyName, 90 | value: value || randomString(), 91 | }); 92 | 93 | const dispose = async () => { 94 | await replDb.del({ 95 | key: keyName, 96 | }); 97 | }; 98 | 99 | return { 100 | keyName, 101 | value, 102 | dispose, 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /modules/tester/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HandshakeStatus } from "@replit/extensions"; 2 | import { 3 | useReplit, 4 | useSetThemeCssVariables, 5 | useThemeValues, 6 | } from "@replit/extensions-react"; 7 | import { useEffect, useRef } from "react"; 8 | import "./App.css"; 9 | import Header from "./components/Header"; 10 | import { useAppState } from "./components/StateContext"; 11 | import TestGroup from "./components/TestGroup"; 12 | import UnitTests from "./tests"; 13 | 14 | export default function App() { 15 | const { status, error } = useReplit(); 16 | const { logs, setLogs } = useAppState(); 17 | const logRef = useRef(null); 18 | 19 | // Access theme values directly 20 | const tokens = useThemeValues(); 21 | 22 | // Apply theme CSS variables 23 | useSetThemeCssVariables(); 24 | 25 | useEffect(() => { 26 | logRef?.current?.scrollTo({ 27 | top: logRef.current.scrollHeight, 28 | behavior: "smooth", 29 | }); 30 | }, [logs, logRef]); 31 | 32 | if (status === HandshakeStatus.Loading) { 33 | return ( 34 |
40 |

Loading...

41 |
42 | ); 43 | } else if (status === HandshakeStatus.Error) { 44 | return ( 45 |
51 |

Error: {error.message}

52 |
53 | ); 54 | } else { 55 | return ( 56 |
62 |
63 | 64 |
65 |
76 | {Object.values(UnitTests) 77 | .sort((a, b) => a.module.localeCompare(b.module)) 78 | .map(({ module }, i) => ( 79 | 80 | ))} 81 |
82 |
83 | 84 |
85 |
86 | Logs 87 | 94 |
95 | {logs.length > 0 ? ( 96 |
97 | {logs.map((l, i) => ( 98 | {l} 99 | ))} 100 |
101 | ) : null} 102 |
103 |
104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /modules/tester/src/components/Test.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Check, X, Loader, Circle } from "react-feather"; 3 | import { Module } from "../types"; 4 | import { useAppState } from "./StateContext"; 5 | import UnitTests from "../tests"; 6 | 7 | export const UnitTest = ({ 8 | k: key, 9 | module, 10 | }: { 11 | k: keyof (typeof UnitTests)[Module]; 12 | module: Module; 13 | }) => { 14 | const { 15 | tests, 16 | testQueue, 17 | setTestQueue, 18 | setLogs, 19 | setPassedTests, 20 | setFailedTests, 21 | } = useAppState(); 22 | const [time, setTime] = useState(0); 23 | const [status, setStatus] = useState< 24 | "passed" | "failed" | "loading" | "idle" 25 | >("idle"); 26 | 27 | const test = tests.find((t) => t.key === key && t.module === module); 28 | 29 | const finishTest = (t: number) => { 30 | setTestQueue((q) => 31 | q.filter((t) => !(t.key === key && t.module === module)) 32 | ); 33 | setTime(Date.now() - t); 34 | }; 35 | 36 | const addLog = (log: string) => { 37 | setLogs((l) => [...l, log]); 38 | }; 39 | 40 | useEffect(() => { 41 | if ( 42 | test && 43 | testQueue[0]?.key === test.key && 44 | testQueue[0]?.module === test.module 45 | ) { 46 | setStatus("loading"); 47 | let t = Date.now(); 48 | 49 | const testFn = UnitTests[module].tests[key]; 50 | if (testFn) { 51 | try { 52 | Promise.race([ 53 | testFn(addLog), 54 | new Promise((_resolve, reject) => 55 | setTimeout(() => reject(new Error("Test timed out")), 10000) 56 | ), 57 | ]) 58 | .then(() => { 59 | addLog(`${key}: ✅`); 60 | setStatus("passed"); 61 | setPassedTests((p) => (p || 0) + 1); 62 | finishTest(t); 63 | }) 64 | .catch((err) => { 65 | addLog(`${key}: ❌ ${err.message}`); 66 | setStatus("failed"); 67 | setFailedTests((f) => (f || 0) + 1); 68 | finishTest(t); 69 | }); 70 | } catch (err) { 71 | addLog(`${key}: ❌ ${String(err)}`); 72 | setStatus("failed"); 73 | setFailedTests((f) => (f || 0) + 1); 74 | finishTest(t); 75 | } 76 | } else { 77 | addLog(`${key}: ❌ No test function found`); 78 | setStatus("failed"); 79 | setFailedTests((f) => (f || 0) + 1); 80 | finishTest(t); 81 | } 82 | } else if (testQueue.some((t) => t.key === key && t.module === module)) { 83 | setStatus("idle"); 84 | } 85 | }, [test, testQueue]); 86 | 87 | return test ? ( 88 |
89 |
90 | {status === "idle" ? : null} 91 | {status === "loading" ? : null} 92 | {status === "passed" ? : null} 93 | {status === "failed" ? : null} 94 |
95 | 96 | {key} 97 | 98 | 99 | {time && status !== "loading" && status !== "idle" ? `${time}ms` : "--"} 100 | 101 |
102 | ) : null; 103 | }; 104 | -------------------------------------------------------------------------------- /modules/extensions/util/signature-plugin/index.cjs: -------------------------------------------------------------------------------- 1 | const { 2 | SignatureReflection, 3 | ReflectionKind, 4 | ReflectionType, 5 | } = require("typedoc"); 6 | 7 | // from https://github.com/TypeStrong/typedoc/issues/1662#issuecomment-907717438 8 | exports.load = function (app) { 9 | // This adds a string representation for function call signatures directly to the JSON output 10 | app.serializer.addSerializer({ 11 | supports(x) { 12 | return x instanceof SignatureReflection; 13 | }, 14 | priority: 0, 15 | toObject(signature, obj) { 16 | // name of the function 17 | const parts = [signature.name]; 18 | 19 | // adds `new` if it's a constructor 20 | if (signature.kind === ReflectionKind.ConstructorSignature) { 21 | if (signature.flags.isAbstract) parts.push("abstract "); 22 | parts.push("new "); 23 | } 24 | 25 | // if it's a generic function, adds the type parameters 26 | if (signature.typeParameters) { 27 | parts.push("<"); 28 | let first = true; 29 | for (const typeParam of signature.typeParameters) { 30 | if (!first) parts.push(", "); 31 | parts.push(typeParam.name); 32 | if (typeParam.type) { 33 | parts.push(" extends ", typeParam.type.toString()); 34 | } 35 | if (typeParam.default) { 36 | parts.push(" = ", typeParam.default.toString()); 37 | } 38 | first = false; 39 | } 40 | parts.push(">"); 41 | } 42 | 43 | // adds the parameters 44 | parts.push("("); 45 | let first = true; 46 | for (const param of signature.parameters || []) { 47 | if (!first) parts.push(", "); 48 | parts.push(param.name, ": ", param.type.toString()); 49 | first = false; 50 | } 51 | parts.push("): "); 52 | 53 | // adds the return type 54 | parts.push(signature.type.toString()); 55 | 56 | obj.stringifiedSignature = parts.join(""); 57 | return obj; 58 | }, 59 | }); 60 | 61 | // This adds a stringified representation of object types to the JSON output 62 | app.serializer.addSerializer({ 63 | supports(x) { 64 | return x instanceof ReflectionType; 65 | }, 66 | priority: 1, 67 | toObject: (x, obj) => { 68 | let oldStringify = x.stringify; 69 | x.stringify = () => { 70 | if (!x.declaration.children) { 71 | if (oldStringify.call(x) === "Object") { 72 | // HACK: usually it's an empty object 73 | return "{}"; 74 | } 75 | return oldStringify.call(x); 76 | } 77 | 78 | return `{ ${x.declaration.children 79 | .map((ch) => `${ch.name}: ${ch.type.stringify()}`) 80 | .join(", ")} }`; 81 | }; 82 | return obj; 83 | }, 84 | }); 85 | 86 | // This adds a stringified representation of interface types to the JSON output 87 | app.serializer.addSerializer({ 88 | supports(x) { 89 | return x.kind === ReflectionKind.Interface; 90 | }, 91 | priority: 0, 92 | toObject: (x, obj) => { 93 | const childrenParts = (x.children ?? []).map( 94 | (c) => ` ${c.name}: ${c.type.toString()},` 95 | ); 96 | const indexSigParts = x.indexSignature?.parameters?.length 97 | ? x.indexSignature.parameters.map( 98 | (p) => 99 | ` [${p.name}: ${p.type.toString()}]: ${ 100 | x.indexSignature.type.name 101 | }` 102 | ) 103 | : ""; 104 | 105 | const partsString = [...childrenParts, ...indexSigParts].join("\n"); 106 | obj.stringifiedInterface = `interface ${x.name} {\n${partsString}\n}`; 107 | 108 | return obj; 109 | }, 110 | }); 111 | }; 112 | -------------------------------------------------------------------------------- /modules/extensions-react/src/hooks/useWatchTextFile.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useReplit } from "./useReplit"; 3 | import { 4 | UseWatchTextFileErrorLike, 5 | UseWatchTextFileLoading, 6 | UseWatchTextFileWatching, 7 | UseWatchTextFileStatus, 8 | } from "../types"; 9 | 10 | /** 11 | * Returns the watching status and contents of a text file at the given `filePath`. Also provides a `writeChange` function that enables you to write a change to the watched text file. 12 | */ 13 | export function useWatchTextFile({ 14 | filePath, 15 | }: { 16 | filePath: string | null | undefined; 17 | }): 18 | | UseWatchTextFileLoading 19 | | UseWatchTextFileWatching 20 | | UseWatchTextFileErrorLike { 21 | const [state, setState] = React.useState< 22 | | UseWatchTextFileLoading 23 | | UseWatchTextFileWatching 24 | | UseWatchTextFileErrorLike 25 | >({ 26 | status: UseWatchTextFileStatus.Loading, 27 | content: null, 28 | watchError: null, 29 | writeChange: null, 30 | }); 31 | 32 | const { replit } = useReplit(); 33 | 34 | React.useEffect(() => { 35 | setState((prev) => { 36 | if (prev.status === UseWatchTextFileStatus.Loading) { 37 | return prev; 38 | } 39 | 40 | return { 41 | status: UseWatchTextFileStatus.Loading, 42 | content: null, 43 | watchError: null, 44 | writeChange: null, 45 | }; 46 | }); 47 | 48 | if (!replit || !filePath) { 49 | return; 50 | } 51 | 52 | // keep a local redudant state so that we don't rely on state.status in the effect 53 | let isWatching = false; 54 | 55 | const dispose = replit.fs.watchTextFile(filePath, { 56 | onReady: ({ initialContent, writeChange, getLatestContent }) => { 57 | isWatching = true; 58 | setState({ 59 | status: UseWatchTextFileStatus.Watching, 60 | content: initialContent, 61 | watchError: null, 62 | writeChange: (changes) => { 63 | if (!isWatching) { 64 | return; 65 | } 66 | 67 | writeChange(changes); 68 | // We must update the state here because the file watcher 69 | // doesn't loop back to us to update the state 70 | setState((prev) => { 71 | if (prev.status !== UseWatchTextFileStatus.Watching) { 72 | throw new Error( 73 | "wrote change to file that was not being watched" 74 | ); 75 | } 76 | 77 | return { 78 | ...prev, 79 | content: getLatestContent(), 80 | }; 81 | }); 82 | }, 83 | }); 84 | }, 85 | onChange: (changes) => { 86 | if (!isWatching) { 87 | return; 88 | } 89 | 90 | setState((prev) => { 91 | if (prev.status !== UseWatchTextFileStatus.Watching) { 92 | throw new Error("got update on an unwatched file"); 93 | } 94 | 95 | return { 96 | ...prev, 97 | content: changes.latestContent, 98 | }; 99 | }); 100 | }, 101 | onError(err) { 102 | setState({ 103 | status: UseWatchTextFileStatus.Error, 104 | content: null, 105 | watchError: err, 106 | writeChange: null, 107 | }); 108 | isWatching = false; 109 | }, 110 | onMoveOrDelete: ({ eventType }) => { 111 | setState({ 112 | status: 113 | eventType === "MOVE" 114 | ? UseWatchTextFileStatus.Moved 115 | : UseWatchTextFileStatus.Deleted, 116 | content: null, 117 | watchError: null, 118 | writeChange: null, 119 | }); 120 | isWatching = false; 121 | }, 122 | }); 123 | 124 | return () => { 125 | isWatching = false; 126 | dispose(); 127 | }; 128 | }, [filePath, replit]); 129 | 130 | return state; 131 | } 132 | -------------------------------------------------------------------------------- /modules/dev/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | #root { 8 | display: flex; 9 | flex-direction: column; 10 | width: 100vw; 11 | height: 100vh; 12 | } 13 | 14 | main { 15 | display: flex; 16 | flex-direction: column; 17 | flex-grow: 1; 18 | font-family: "IBM Plex Sans", sans-serif; 19 | background: var(--background-root); 20 | color: var(--foreground-default); 21 | } 22 | 23 | .header { 24 | display: flex; 25 | padding: 8px; 26 | justify-content: space-between; 27 | align-content: center; 28 | align-items: center; 29 | border-bottom: solid 1px var(--background-higher); 30 | } 31 | 32 | .button { 33 | border: none; 34 | padding: 8px; 35 | border-radius: 8px; 36 | background: var(--accent-primary-dimmer); 37 | color: var(--foreground-default); 38 | font-family: "IBM Plex Sans", sans-serif; 39 | line-height: 1; 40 | cursor: pointer; 41 | transition: 0.25s; 42 | font-size: 14px; 43 | } 44 | 45 | .button:hover { 46 | background: var(--accent-primary-default); 47 | } 48 | 49 | .button:active, 50 | .button:focus { 51 | box-shadow: 0 0 0 1px var(--accent-primary-strongest); 52 | } 53 | 54 | .button:disabled { 55 | opacity: 0.5; 56 | cursor: not-allowed; 57 | } 58 | 59 | .button.small { 60 | padding: 4px; 61 | font-size: 12px; 62 | } 63 | 64 | .test { 65 | display: flex; 66 | flex-direction: row; 67 | align-content: center; 68 | align-items: center; 69 | padding: 4px; 70 | } 71 | 72 | .test-icon { 73 | border-radius: 50%; 74 | padding: 4px; 75 | font-size: 12px; 76 | color: var(--foreground-default); 77 | background: var(--background-higher); 78 | display: flex; 79 | align-content: center; 80 | justify-content: center; 81 | } 82 | 83 | .test-icon.passed { 84 | background: var(--accent-positive-dimmer); 85 | } 86 | 87 | .test-icon.failed { 88 | background: var(--accent-negative-dimmer); 89 | } 90 | 91 | .test-icon.loading > * { 92 | animation: spin 1s linear infinite; 93 | } 94 | 95 | @keyframes spin { 96 | 100% { 97 | transform: rotate(360deg); 98 | } 99 | } 100 | 101 | .test-text { 102 | flex-grow: 1; 103 | overflow: hidden; 104 | text-overflow: ellipsis; 105 | margin: 0 8px; 106 | white-space: nowrap; 107 | font-size: 12px; 108 | } 109 | 110 | .test-time { 111 | color: var(--foreground-dimmest); 112 | font-size: 12px; 113 | } 114 | 115 | .testGroup { 116 | display: flex; 117 | flex-direction: column; 118 | border-radius: 8px; 119 | overflow: hidden; 120 | border: solid 1px var(--background-higher); 121 | margin-bottom: 8px; 122 | } 123 | 124 | .testGroup-header { 125 | padding: 8px; 126 | display: flex; 127 | background: var(--background-higher); 128 | align-content: center; 129 | align-items: center; 130 | } 131 | 132 | .testGroup-title { 133 | flex-grow: 1; 134 | font-weight: bold; 135 | font-size: 16px; 136 | } 137 | 138 | .dropdown-toggle { 139 | padding: 6px; 140 | margin-left: 8px; 141 | } 142 | 143 | .testGroupTests > .test { 144 | border-bottom: 1px solid var(--background-higher); 145 | } 146 | 147 | .testGroupTests > .test:last-of-type { 148 | border-bottom: none; 149 | } 150 | 151 | .logs { 152 | } 153 | 154 | .logs-head { 155 | display: flex; 156 | align-items: center; 157 | padding: 8px; 158 | justify-content: space-between; 159 | border-top: 1px solid var(--background-higher); 160 | } 161 | 162 | .logs-scroll { 163 | padding: 8px; 164 | background: var(--background-root); 165 | min-height: 128px; 166 | max-height: 128px; 167 | overflow: auto; 168 | border-top: solid 1px var(--background-higher); 169 | font-family: monospace; 170 | font-size: 12px; 171 | display: flex; 172 | flex-direction: column; 173 | position: relative; 174 | } 175 | 176 | .test-stats { 177 | font-size: 12px; 178 | } 179 | 180 | #passed { 181 | color: var(--accent-primary-default); 182 | } 183 | 184 | #failed { 185 | color: var(--accent-negative-default); 186 | } 187 | -------------------------------------------------------------------------------- /modules/tester/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | #root { 8 | display: flex; 9 | flex-direction: column; 10 | width: 100vw; 11 | height: 100vh; 12 | } 13 | 14 | main { 15 | display: flex; 16 | flex-direction: column; 17 | flex-grow: 1; 18 | font-family: "IBM Plex Sans", sans-serif; 19 | background: var(--background-root); 20 | color: var(--foreground-default); 21 | } 22 | 23 | .header { 24 | display: flex; 25 | padding: 8px; 26 | justify-content: space-between; 27 | align-content: center; 28 | align-items: center; 29 | border-bottom: solid 1px var(--background-higher); 30 | } 31 | 32 | .button { 33 | border: none; 34 | padding: 8px; 35 | border-radius: 8px; 36 | background: var(--accent-primary-dimmer); 37 | color: var(--foreground-default); 38 | font-family: "IBM Plex Sans", sans-serif; 39 | line-height: 1; 40 | cursor: pointer; 41 | transition: 0.25s; 42 | font-size: 14px; 43 | } 44 | 45 | .button:hover { 46 | background: var(--accent-primary-default); 47 | } 48 | 49 | .button:active, 50 | .button:focus { 51 | box-shadow: 0 0 0 1px var(--accent-primary-strongest); 52 | } 53 | 54 | .button:disabled { 55 | opacity: 0.5; 56 | cursor: not-allowed; 57 | } 58 | 59 | .button.small { 60 | padding: 4px; 61 | font-size: 12px; 62 | } 63 | 64 | .test { 65 | display: flex; 66 | flex-direction: row; 67 | align-content: center; 68 | align-items: center; 69 | padding: 4px; 70 | } 71 | 72 | .test-icon { 73 | border-radius: 50%; 74 | padding: 4px; 75 | font-size: 12px; 76 | color: var(--foreground-default); 77 | background: var(--background-higher); 78 | display: flex; 79 | align-content: center; 80 | justify-content: center; 81 | } 82 | 83 | .test-icon.passed { 84 | background: var(--accent-positive-dimmer); 85 | } 86 | 87 | .test-icon.failed { 88 | background: var(--accent-negative-dimmer); 89 | } 90 | 91 | .test-icon.loading > * { 92 | animation: spin 1s linear infinite; 93 | } 94 | 95 | @keyframes spin { 96 | 100% { 97 | transform: rotate(360deg); 98 | } 99 | } 100 | 101 | .test-text { 102 | flex-grow: 1; 103 | overflow: hidden; 104 | text-overflow: ellipsis; 105 | margin: 0 8px; 106 | white-space: nowrap; 107 | font-size: 12px; 108 | } 109 | 110 | .test-time { 111 | color: var(--foreground-dimmest); 112 | font-size: 12px; 113 | } 114 | 115 | .testGroup { 116 | display: flex; 117 | flex-direction: column; 118 | border-radius: 8px; 119 | overflow: hidden; 120 | border: solid 1px var(--background-higher); 121 | margin-bottom: 8px; 122 | } 123 | 124 | .testGroup-header { 125 | padding: 8px; 126 | display: flex; 127 | background: var(--background-higher); 128 | align-content: center; 129 | align-items: center; 130 | } 131 | 132 | .testGroup-title { 133 | flex-grow: 1; 134 | font-weight: bold; 135 | font-size: 16px; 136 | } 137 | 138 | .dropdown-toggle { 139 | padding: 6px; 140 | margin-left: 8px; 141 | } 142 | 143 | .testGroupTests > .test { 144 | border-bottom: 1px solid var(--background-higher); 145 | } 146 | 147 | .testGroupTests > .test:last-of-type { 148 | border-bottom: none; 149 | } 150 | 151 | .logs { 152 | } 153 | 154 | .logs-head { 155 | display: flex; 156 | align-items: center; 157 | padding: 8px; 158 | justify-content: space-between; 159 | border-top: 1px solid var(--background-higher); 160 | } 161 | 162 | .logs-scroll { 163 | padding: 8px; 164 | background: var(--background-root); 165 | min-height: 128px; 166 | max-height: 128px; 167 | overflow: auto; 168 | border-top: solid 1px var(--background-higher); 169 | font-family: monospace; 170 | font-size: 12px; 171 | display: flex; 172 | flex-direction: column; 173 | position: relative; 174 | } 175 | 176 | .test-stats { 177 | font-size: 12px; 178 | } 179 | 180 | #passed { 181 | color: var(--accent-primary-default); 182 | } 183 | 184 | #failed { 185 | color: var(--accent-negative-default); 186 | } 187 | -------------------------------------------------------------------------------- /modules/extensions/src/api/fs/index.ts: -------------------------------------------------------------------------------- 1 | import { extensionPort, proxy } from "../..//util/comlink"; 2 | import { 3 | WatchDirListeners, 4 | WatchFileListeners, 5 | WatchTextFileListeners, 6 | } from "../../types"; 7 | import { fileWatcherManager } from "./watching"; 8 | 9 | /** 10 | * Reads the file specified at `path` and returns an object containing the contents, or an object containing an error if there was one. Required [permissions](/extensions/api/manifest#scopetype): `read`. 11 | */ 12 | export async function readFile( 13 | path: string, 14 | encoding: "utf8" | "binary" | null = "utf8" 15 | ) { 16 | return extensionPort.readFile(path, encoding); 17 | } 18 | 19 | /** 20 | * Writes the file specified at `path` with the contents `content`. Required [permissions](/extensions/api/manifest#scopetype): `read`, `write-exec`. 21 | */ 22 | export async function writeFile(path: string, content: string | Blob) { 23 | return extensionPort.writeFile(path, content); 24 | } 25 | 26 | /** 27 | * Reads the directory specified at `path` and returns an object containing the contents, or an object containing an error if there was one. Required [permissions](/extensions/api/manifest#scopetype): `read`. 28 | */ 29 | export async function readDir(path: string) { 30 | return extensionPort.readDir(path); 31 | } 32 | 33 | /** 34 | * Creates a directory at the specified path. Required [permissions](/extensions/api/manifest#scopetype): `read`, `write-exec`. 35 | */ 36 | export async function createDir(path: string) { 37 | return extensionPort.createDir(path); 38 | } 39 | 40 | /** 41 | * Deletes the file at the specified path. Required [permissions](/extensions/api/manifest#scopetype): `read`, `write-exec`. 42 | */ 43 | export async function deleteFile(path: string) { 44 | return extensionPort.deleteFile(path); 45 | } 46 | 47 | /** 48 | * Deletes the directory at the specified path. Required [permissions](/extensions/api/manifest#scopetype): `read`, `write-exec`. 49 | */ 50 | export async function deleteDir(path: string) { 51 | return extensionPort.deleteDir(path); 52 | } 53 | 54 | /** 55 | * Moves the file or directory at `from` to `to`. Required [permissions](/extensions/api/manifest#scopetype): `read`, `write-exec`. 56 | */ 57 | export async function move(path: string, to: string) { 58 | return extensionPort.move(path, to); 59 | } 60 | 61 | /** 62 | * Copies the file at `from` to `to`. Required [permissions](/extensions/api/manifest#scopetype): `read`, `write-exec`. 63 | */ 64 | export async function copyFile(path: string, to: string) { 65 | return extensionPort.copyFile(path, to); 66 | } 67 | 68 | /** 69 | * Watches the file at `path` for changes with the provided `listeners`. Returns a dispose method which cleans up the listeners. Required [permissions](/extensions/api/manifest#scopetype): `read`. 70 | */ 71 | export async function watchFile( 72 | path: string, 73 | listeners: WatchFileListeners, 74 | encoding: "utf8" | "binary" = "binary" 75 | ) { 76 | // Note: comlink does not let us test for functions being present, so we provide default functions for all callbacks in case the user does not pass those, to keep the API flexible 77 | return extensionPort.watchFile( 78 | path, 79 | proxy({ 80 | onMoveOrDelete: () => {}, 81 | onError: () => {}, 82 | ...listeners, 83 | }), 84 | encoding 85 | ); 86 | } 87 | 88 | /** 89 | * Watches file events (move, create, delete) in the specified directory at the given `path`. Returns a dispose method which cleans up the listeners. Required [permissions](/extensions/api/manifest#scopetype): `read`. 90 | */ 91 | export async function watchDir(path: string, listeners: WatchDirListeners) { 92 | return extensionPort.watchDir( 93 | path, 94 | proxy({ 95 | onMoveOrDelete: () => {}, 96 | ...listeners, 97 | }) 98 | ); 99 | } 100 | 101 | /** 102 | * Watches a text file at `path` for changes with the provided `listeners`. Returns a dispose method which cleans up the listeners. 103 | * 104 | * Use this for watching text files, and receive changes as versioned operational transform (OT) operations annotated with their source. 105 | * 106 | * Required [permissions](/extensions/api/manifest#scopetype): `read`. 107 | */ 108 | export function watchTextFile(path: string, listeners: WatchTextFileListeners) { 109 | return fileWatcherManager.watch(path, listeners); 110 | } 111 | -------------------------------------------------------------------------------- /modules/extensions/changelog.md: -------------------------------------------------------------------------------- 1 | ## 1.10.0 2 | 3 | - Simplified commands API 4 | 5 | ## 1.9.0 6 | 7 | - Add commands API 8 | 9 | ## 1.9.0-beta.0 10 | 11 | - added Command function 12 | - fixed duplicate console patching 13 | 14 | ## 1.8.0 15 | 16 | - improve logging: 17 | - non serializable logs lead to warnings in the console 18 | - numbers, booleans, nulls, and undefined work now 19 | 20 | ## 1.8.0-beta.0 21 | 22 | - fix Auth API bug 23 | 24 | ## 1.7.0 25 | 26 | - `debug` API module 27 | 28 | ## 1.6.0 29 | 30 | - `auth` API namespace has been released 31 | 32 | ## 1.5.0 33 | 34 | - `exec` moves out of experimental 35 | - Experimental APIs are now a permission 36 | 37 | ## 1.2.0 - 1.5.0-beta.0 38 | 39 | - Experimenting with `exec` API. 40 | 41 | ## 1.1.2-beta.0 42 | 43 | - Experimental `editor` API module 44 | 45 | ## 1.1.2 46 | 47 | - No initial changes to client 48 | 49 | ## 1.3.0-beta.1 50 | 51 | - Moved exec to experimental namespace 52 | 53 | ## 1.3.0-beta.0 54 | 55 | - Basic exec api 56 | 57 | ## 1.1.0 58 | 59 | - Wait for window to be ready before handshake 60 | 61 | ## 1.0.0 62 | 63 | - Initial release 64 | 65 | ## 0.36.0 66 | 67 | - Added `fs.watchDir` 68 | 69 | ## 0.35.0 70 | 71 | - Some polish updates on the init function 72 | 73 | ## 0.34.0 74 | 75 | - Added subscription status for users, and `iconUrl` + `imageUrl` to Repls (data module) 76 | - Added the `useIsExtension` hook for the react module 77 | 78 | ## 0.33.1 79 | 80 | - Added support for NodeJS module Resolution 81 | 82 | ## 0.33.0 83 | 84 | - React Type Declarations work now! 85 | - Exported status types for the `useReplit` and `useWatchTextFile` hook to `@replit/extensions/react` 86 | 87 | ## 0.32.0 88 | 89 | - More stable and ergonomic file watcher, some breaking changes included 90 | - No longer using `useLayoutEffect` in the `useReplitEffect` hook 91 | 92 | ## 0.31.0 93 | 94 | - added the Themes API module 95 | 96 | ## 0.30.3 97 | 98 | - added the `useActiveFile` React Hook 99 | 100 | ## 0.30.1 101 | 102 | - added session.getActiveFile 103 | 104 | ## 0.30.0 105 | 106 | - added session.onActiveFileChange 107 | 108 | ## 0.29.2 109 | 110 | - Added the data module 111 | 112 | ## 0.28.2 113 | 114 | - Updated the `useWatchTextFile` React hook 115 | 116 | ## 0.27.0 117 | 118 | - Added messages API 119 | 120 | ## 0.26.0 121 | 122 | - Added encoding option to readFile 123 | 124 | ## 0.25.0 125 | 126 | - Removed layout 127 | 128 | ## 0.24.0 129 | 130 | - Add useReplitEffect 131 | - Fix useWatchTextFile 132 | - Improve useReplit hook 133 | - Fix issues with SSR 134 | - Fix typing 135 | 136 | ## 0.24.0-test 137 | 138 | - Fix types 139 | 140 | ## 0.23.0 141 | 142 | - added themes API 143 | 144 | ## 0.21.0 145 | 146 | - added useWatchTextFile() hook to @replit/extensions/react 147 | 148 | ## 0.20.0 149 | 150 | - fixed global name bug for iife 151 | 152 | ## 0.19.0 153 | 154 | - removed deprecated APIs 155 | 156 | ## 0.18.0 157 | 158 | - Switched to tsup bundler 159 | - made /example its own package, and improved how linking works 160 | - got rid of janky copy based linking 161 | - improved devex for the replit/extensions package as a whole 162 | - added @replit/extensions/react as an export 163 | - added a useReplit hook 164 | 165 | ## 0.17.1 166 | 167 | - upgraded json5 168 | 169 | ## 0.17.0 170 | 171 | - fixed timeout 172 | - bumped default timeout to 2000 173 | 174 | ## 0.16.0 175 | 176 | - added `me` API 177 | - added `me.filePath()` to get the current filePath 178 | 179 | ## 0.15.0 180 | 181 | - Added activatePane call 182 | 183 | ## 0.13.0 184 | 185 | - removed old postMessage code 186 | 187 | ## 0.12.0 188 | 189 | - Added layout.getLayoutState, layout.setLayoutState 190 | - Exported all types 191 | 192 | ## 0.11.0 193 | 194 | - MIT license 195 | 196 | ## 0.10.0 197 | 198 | - added replDb.delete API 199 | 200 | ## 0.9.0 201 | 202 | - added fs.watchTextFile API 203 | 204 | ## 0.8.0 205 | 206 | - addded typedocs, and reorganized replDb 207 | 208 | ## 0.7.4 209 | 210 | - added some warnings for temporary APIs 211 | 212 | ## 0.7.3 213 | 214 | - added typings 215 | 216 | ## 0.7.0 217 | 218 | - added ESM support in addition to the current IIFE support 219 | 220 | ## 0.6.0 221 | 222 | - added watchFile 223 | 224 | ## 0.5.0 225 | 226 | - added layout 227 | - fixed exports 228 | 229 | ## 0.4.0 230 | 231 | - migrated to use comlink 232 | 233 | ## 0.0.2 234 | 235 | - fixed build steps / cleanup 236 | 237 | ## 0.0.1 238 | 239 | hi 240 | -------------------------------------------------------------------------------- /modules/extensions/src/types/data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Replit user 3 | */ 4 | export interface User { 5 | id: number; 6 | username: string; 7 | image: string; 8 | bio?: string; 9 | 10 | // SocialUserData fragment 11 | url?: string; 12 | socials?: Array; 13 | firstName?: string; 14 | lastName?: string; 15 | displayName?: string; 16 | fullName?: string; 17 | followCount?: number; 18 | followerCount?: number; 19 | 20 | // PlanUserData fragment 21 | isUserHacker?: boolean; 22 | isUserPro?: boolean; 23 | 24 | // RolesUserData fragment 25 | roles?: Array; 26 | } 27 | 28 | /** 29 | * Extended values for the current user 30 | */ 31 | export interface CurrentUser extends User {} 32 | 33 | /** 34 | * A user social media link 35 | */ 36 | export interface UserSocial { 37 | id: number; 38 | url: string; 39 | type: UserSocialType; 40 | } 41 | 42 | /** 43 | * An enumerated type of social media links 44 | */ 45 | export enum UserSocialType { 46 | twitter = "twitter", 47 | github = "github", 48 | linkedin = "linkedin", 49 | website = "website", 50 | youtube = "youtube", 51 | twitch = "twitch", 52 | facebook = "facebook", 53 | discord = "discord", 54 | } 55 | 56 | /** 57 | * A user role 58 | */ 59 | export interface UserRole { 60 | id: number; 61 | name: string; 62 | key: string; 63 | tagline: string; 64 | } 65 | 66 | /** 67 | * A Repl 68 | */ 69 | export interface Repl { 70 | id: string; 71 | url: string; 72 | title: string; 73 | description: string; 74 | timeCreated: string; 75 | slug: string; 76 | isPrivate: boolean; 77 | 78 | // SocialReplData fragment 79 | likeCount?: number; 80 | publicForkCount?: number; 81 | runCount?: number; 82 | commentCount?: number; 83 | tags?: Array; 84 | iconUrl?: string; 85 | imageUrl?: string; 86 | 87 | // CommentsReplData fragment 88 | comments?: ReplCommentConnection; 89 | 90 | // OwnerData fragment 91 | owner?: ReplOwner; 92 | 93 | // MultiplayersData fragment 94 | multiplayers?: Array; 95 | } 96 | 97 | /** 98 | * A Repl Owner, can be either a User or a Team 99 | */ 100 | export interface ReplOwner { 101 | id: number; 102 | username: string; 103 | image: string; 104 | __typename: string; 105 | description?: string; 106 | } 107 | 108 | /** 109 | * A Repl tag 110 | */ 111 | export interface Tag { 112 | id: string; 113 | isOfficial: boolean; 114 | } 115 | 116 | /** 117 | * A Repl Comment 118 | */ 119 | export interface ReplComment { 120 | id: number; 121 | body: string; 122 | user: User; 123 | } 124 | 125 | /** 126 | * An array of ReplComments as items 127 | */ 128 | export interface ReplCommentConnection { 129 | items: Array; 130 | } 131 | 132 | /** 133 | * Editor Preferences 134 | */ 135 | export interface EditorPreferences { 136 | __typename: string; 137 | fontSize: number; 138 | indentIsSpaces: boolean; 139 | indentSize: number; 140 | keyboardHandler: string; 141 | wrapping: boolean; 142 | codeIntelligence: boolean; 143 | codeSuggestion: boolean; 144 | multiselectModifierKey: string; 145 | minimapDisplay: string; 146 | } 147 | 148 | /** 149 | * Options for user queries 150 | */ 151 | export interface UserDataInclusion { 152 | includeSocialData?: boolean; 153 | includeRoles?: boolean; 154 | includePlan?: boolean; 155 | } 156 | 157 | /** 158 | * Options for the currentUser query 159 | */ 160 | export interface CurrentUserDataInclusion { 161 | includeSocialData?: boolean; 162 | includeRoles?: boolean; 163 | includePlan?: boolean; 164 | } 165 | 166 | /** 167 | * Options for repl queries 168 | */ 169 | export interface ReplDataInclusion { 170 | includeSocialData?: boolean; 171 | includeComments?: boolean; 172 | includeOwner?: boolean; 173 | includeMultiplayers?: boolean; 174 | } 175 | 176 | /** 177 | * A graphql response 178 | */ 179 | export type GraphResponse = Promise; 180 | 181 | /** 182 | * A graphql response for the repl query 183 | */ 184 | export type ReplQueryOutput = GraphResponse<{ repl: Repl }>; 185 | 186 | /** 187 | * A graphql response for the userByUsername query 188 | */ 189 | export type UserByUsernameQueryOutput = GraphResponse<{ userByUsername: User }>; 190 | 191 | /** 192 | * A graphql response for the user query 193 | */ 194 | export type UserQueryOutput = GraphResponse<{ user: User }>; 195 | 196 | /** 197 | * A graphql response for the currentUser query 198 | */ 199 | export type CurrentUserQueryOutput = GraphResponse<{ user: CurrentUser }>; 200 | -------------------------------------------------------------------------------- /modules/extensions/src/types/fs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Filesystem node type 3 | */ 4 | export enum FsNodeType { 5 | File = "FILE", 6 | Directory = "DIRECTORY", 7 | } 8 | 9 | /** 10 | * A base interface for nodes, just includes 11 | * the type of the node and the path, This interface 12 | * does not expose the node's content/children 13 | */ 14 | export interface FsNode { 15 | path: string; 16 | type: FsNodeType; 17 | } 18 | 19 | /** 20 | * An array of Filesystem Nodes 21 | */ 22 | export type FsNodeArray = Array; 23 | 24 | /** 25 | * A directory child node - a file or a folder. 26 | */ 27 | export interface DirectoryChildNode { 28 | filename: string; 29 | type: FsNodeType; 30 | } 31 | 32 | /** 33 | * A file change event type 34 | */ 35 | export enum ChangeEventType { 36 | Create = "CREATE", 37 | Move = "MOVE", 38 | Delete = "DELETE", 39 | Modify = "MODIFY", 40 | } 41 | 42 | /** 43 | * Fired when a file is moved 44 | */ 45 | export interface MoveEvent { 46 | eventType: ChangeEventType.Move; 47 | node: FsNode; 48 | to: string; 49 | } 50 | 51 | /** 52 | * Fired when a file is deleted 53 | */ 54 | export interface DeleteEvent { 55 | eventType: ChangeEventType.Delete; 56 | node: FsNode; 57 | } 58 | 59 | /** 60 | * Fires when a non-text file is changed 61 | */ 62 | export type WatchFileOnChangeListener = ( 63 | newContent: T 64 | ) => void; 65 | 66 | /** 67 | * Fires when watching a non-text file fails 68 | */ 69 | export type WatchFileOnErrorListener = (error: string) => void; 70 | 71 | /** 72 | * Fires when a non-text file is moved or deleted 73 | */ 74 | export type WatchFileOnMoveOrDeleteListener = ( 75 | moveOrDeleteEvent: MoveEvent | DeleteEvent 76 | ) => void; 77 | 78 | /** 79 | * A set of listeners for watching a non-text file 80 | */ 81 | export interface WatchFileListeners { 82 | onChange: WatchFileOnChangeListener; 83 | onError?: WatchFileOnErrorListener; 84 | onMoveOrDelete?: WatchFileOnMoveOrDeleteListener; 85 | } 86 | 87 | /** 88 | * A written text change for the WriteChange function exposed by WatchTextFileListeners.onReady 89 | */ 90 | export interface TextChange { 91 | from: number; 92 | to?: number; 93 | insert?: string; 94 | } 95 | 96 | /** 97 | * Writes a change to a watched file using the TextChange interface 98 | */ 99 | export type WriteChange = (changes: TextChange | Array) => void; 100 | 101 | /** 102 | * Returns the latest content of a watched file as a string 103 | */ 104 | export type GetLatestContent = () => string; 105 | 106 | /** 107 | * A set of listeners and values exposed by WatchTextFileListeners.onReady 108 | */ 109 | export interface TextFileReadyEvent { 110 | writeChange: WriteChange; 111 | getLatestContent: GetLatestContent; 112 | initialContent: string; 113 | } 114 | 115 | /** 116 | * Signifies a change when a text file's text content is updated 117 | */ 118 | export interface TextFileOnChangeEvent { 119 | changes: Array; 120 | latestContent: string; 121 | } 122 | 123 | /** 124 | * Fires when a text file watcher is ready 125 | */ 126 | export type WatchTextFileOnReadyListener = ( 127 | readyEvent: TextFileReadyEvent 128 | ) => void; 129 | 130 | /** 131 | * Fires when a watched text file's text content is updated 132 | */ 133 | export type WatchTextFileOnChangeListener = ( 134 | changeEvent: TextFileOnChangeEvent 135 | ) => void; 136 | 137 | /** 138 | * Fires when watching a text file fails 139 | */ 140 | export type WatchTextFileOnErrorListener = (error: string) => void; 141 | 142 | /** 143 | * Fires when a watched text file is moved or deleted 144 | */ 145 | export type WatchTextFileOnMoveOrDeleteListener = ( 146 | moveOrDeleteEvent: MoveEvent | DeleteEvent 147 | ) => void; 148 | 149 | /** 150 | * A set of listeners for watching a text file 151 | */ 152 | export interface WatchTextFileListeners { 153 | onReady: WatchTextFileOnReadyListener; 154 | onChange?: WatchTextFileOnChangeListener; 155 | onError?: WatchTextFileOnErrorListener; 156 | onMoveOrDelete?: WatchTextFileOnMoveOrDeleteListener; 157 | } 158 | 159 | /** 160 | * Fires when watching a directory fails 161 | */ 162 | export type WatchDirOnErrorListener = ( 163 | err: Error, 164 | extraInfo?: Record 165 | ) => void; 166 | 167 | /** 168 | * Fires when a directory's child nodes change 169 | */ 170 | export type WatchDirOnChangeListener = (children: FsNodeArray) => void; 171 | 172 | /** 173 | * Fires when a watched directory is moved or deleted 174 | */ 175 | export type WatchDirOnMoveOrDeleteListener = ( 176 | event: DeleteEvent | MoveEvent 177 | ) => void; 178 | 179 | /** 180 | * A set of listeners for watching a directory 181 | */ 182 | export interface WatchDirListeners { 183 | onChange: WatchDirOnChangeListener; 184 | onMoveOrDelete?: WatchDirOnMoveOrDeleteListener; 185 | onError: WatchDirOnErrorListener; 186 | } 187 | -------------------------------------------------------------------------------- /modules/extensions/src/auth/verify.ts: -------------------------------------------------------------------------------- 1 | import { ponyfillEd25519 } from "./ed25519"; 2 | import * as base64 from "./base64"; 3 | 4 | const TOLERANCE = 60; 5 | 6 | export function decodeComponentToJSON(component: string) { 7 | try { 8 | return JSON.parse(decoder.decode(base64.decode(component))); 9 | } catch (e) { 10 | throw new Error("Invalid JWT. Failed to parse component"); 11 | } 12 | } 13 | 14 | export function decodeProtectedHeader(token: string) { 15 | const { 0: protectedHeader } = token.split("."); 16 | 17 | if (typeof protectedHeader !== "string") { 18 | throw new Error("Invalid JWT. JWT must have 3 parts"); 19 | } 20 | 21 | return decodeComponentToJSON(protectedHeader); 22 | } 23 | 24 | const encoder = new TextEncoder(); 25 | const decoder = new TextDecoder(); 26 | 27 | function concat(...buffers: Uint8Array[]): Uint8Array { 28 | const size = buffers.reduce((acc, { length }) => acc + length, 0); 29 | const buf = new Uint8Array(size); 30 | let i = 0; 31 | buffers.forEach((buffer) => { 32 | buf.set(buffer, i); 33 | i += buffer.length; 34 | }); 35 | return buf; 36 | } 37 | 38 | export async function verifyJWTAndDecode(token: string, key: string) { 39 | if (typeof token !== "string") { 40 | throw new TypeError("JWT must be a string"); 41 | } 42 | 43 | const { 44 | 0: protectedHeader, 45 | 1: payload, 46 | 2: signature, 47 | length, 48 | } = token.split("."); 49 | 50 | if ( 51 | length !== 3 || 52 | typeof protectedHeader !== "string" || 53 | typeof payload !== "string" || 54 | typeof signature !== "string" 55 | ) { 56 | throw new Error("Invalid Token. Token must have 3 parts"); 57 | } 58 | 59 | // 1. Check header for alg and typ 60 | let parsedProt = decodeComponentToJSON(protectedHeader); 61 | 62 | const { alg, typ } = parsedProt; 63 | 64 | if (!typ || typeof typ !== "string") { 65 | throw new Error("Invalid Token. Missing typ in protected header"); 66 | } 67 | 68 | if (typ !== "JWT") { 69 | throw new Error("Invalid JWT. Expected typ to be JWT"); 70 | } 71 | 72 | if (!alg || typeof alg !== "string") { 73 | throw new Error("Invalid JWT. Missing alg in protected header"); 74 | } 75 | 76 | if (alg !== "EdDSA") { 77 | throw new Error("Invalid JWT. Expected alg to be EdDSA"); 78 | } 79 | 80 | // 2. Validate the signature 81 | const data = concat( 82 | encoder.encode(protectedHeader ?? ""), 83 | encoder.encode("."), 84 | encoder.encode(payload ?? "") 85 | ); 86 | const decodedSignature = base64.decode(signature); 87 | const verified = await verifySignature(key, decodedSignature, data); 88 | 89 | if (!verified) { 90 | throw new Error("Invalid JWT. Signature verification failed"); 91 | } 92 | 93 | let parsedPayload = decodeComponentToJSON(payload); 94 | 95 | // 3. Validate claims in the payload 96 | const now = Math.floor(Date.now() / 1000); 97 | 98 | let iat = parsedPayload.iat; 99 | if (typeof iat !== "number") { 100 | throw new Error("Invalid JWT. Missing claim iat in payload"); 101 | } 102 | 103 | if (iat > now + TOLERANCE) { 104 | throw new Error("Invalid JWT. iat claim must be in the past"); 105 | } 106 | 107 | let exp = parsedPayload.exp; 108 | if (typeof exp !== "number") { 109 | throw new Error("Invalid JWT. Missing claim exp in payload"); 110 | } 111 | 112 | if (exp < now + TOLERANCE) { 113 | throw new Error("Expired JWT. exp claim must be in the future"); 114 | } 115 | 116 | return { 117 | protectedHeader: parsedProt, 118 | payload: parsedPayload, 119 | }; 120 | } 121 | 122 | const verifySignature = async ( 123 | key: string, 124 | sig: Uint8Array | Buffer, 125 | data: Uint8Array | Buffer 126 | ) => { 127 | if (typeof process === "undefined") { 128 | // Browser 129 | return verifyBrowser(key, sig, data); 130 | } else { 131 | // Node 132 | return verifyNode(key, sig, data); 133 | } 134 | }; 135 | 136 | const verifyBrowser = async ( 137 | key: string, 138 | signature: Uint8Array | Buffer, 139 | data: Uint8Array | Buffer 140 | ) => { 141 | const subtle = ponyfillEd25519(crypto.subtle); 142 | 143 | if (!subtle) { 144 | throw new Error("Ed25519 is not supported in this browser"); 145 | } 146 | 147 | let decodedKey = base64.decode( 148 | key.replace(/(?:-----(?:BEGIN|END) PUBLIC KEY-----|\s)/g, "") 149 | ); 150 | 151 | const cryptoKey = await subtle.importKey( 152 | "spki", 153 | decodedKey, 154 | { name: "Ed25519" }, 155 | false, 156 | ["verify"] 157 | ); 158 | 159 | const res = await subtle.verify( 160 | { name: "Ed25519" }, 161 | cryptoKey, 162 | signature, 163 | data 164 | ); 165 | 166 | return res; 167 | }; 168 | 169 | const verifyNode = async ( 170 | key: string, 171 | signature: Uint8Array | Buffer, 172 | data: Uint8Array | Buffer 173 | ) => { 174 | try { 175 | const crypto: typeof import("crypto") = require("crypto"); 176 | 177 | const keyObject = crypto.createPublicKey(key); 178 | 179 | if (keyObject.type !== "public") { 180 | throw new TypeError("The key is not a public key"); 181 | } 182 | 183 | if (keyObject.asymmetricKeyType !== "ed25519") { 184 | throw new TypeError("The key is not of type ed25519"); 185 | } 186 | 187 | return await crypto.verify(undefined, data, keyObject, signature); 188 | } catch { 189 | return false; 190 | } 191 | }; 192 | -------------------------------------------------------------------------------- /modules/extensions/src/apis.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "init", 4 | "description": "The `init()` method initializes the Extension, establishes a handshake with the Repl, and adds an event listener to the window object. It takes as an argument an object containing optional parameters for the initialization process. It returns a function that removes the event listener added to the window object.", 5 | "typeNames": ["ReplitInitArgs", "ReplitInitOutput", "HandshakeStatus"] 6 | }, 7 | { 8 | "name": "fs", 9 | "description": "The fs or filesystem API allows you to create, read, and modify files on the repl's filesystem.", 10 | "typeNames": [ 11 | "WatchFileListeners", 12 | "WatchTextFileListeners", 13 | "WatchFileOnChangeListener", 14 | "WatchFileOnErrorListener", 15 | "WatchFileOnMoveOrDeleteListener", 16 | "WatchTextFileOnReadyListener", 17 | "WatchTextFileOnChangeListener", 18 | "WatchTextFileOnErrorListener", 19 | "WatchTextFileOnMoveOrDeleteListener", 20 | "FsNode", 21 | "FsNodeType", 22 | "FsNodeArray", 23 | "TextFileReadyEvent", 24 | "WriteChange", 25 | "GetLatestContent", 26 | "MoveEvent", 27 | "DeleteEvent", 28 | "TextFileWatcherReadyEvent", 29 | "TextFileWatcherOnChangeEvent", 30 | "TextChange", 31 | "TextFileOnChangeEvent", 32 | "WatchDirListeners", 33 | "WatchDirOnErrorListener", 34 | "WatchDirOnChangeListener", 35 | "WatchDirOnMoveOrDeleteListener", 36 | "ChangeEventType", 37 | "FsMoveEvent", 38 | "FsDeleteEvent", 39 | "DisposerFunction", 40 | "DirectoryChildNode" 41 | ] 42 | }, 43 | { 44 | "name": "replDb", 45 | "description": "ReplDB is a simple key-value store available on all repls by default. Extensions can use ReplDB to store repl specific data.", 46 | "typeNames": ["NullableStrError", "StrError"] 47 | }, 48 | { 49 | "name": "messages", 50 | "description": "The messages API allows you to send custom notices in the Replit workspace.", 51 | "typeNames": [] 52 | }, 53 | { 54 | "name": "data", 55 | "description": "The data API allows you to get information and metadata exposed from Replit's GraphQL API.", 56 | "typeNames": [ 57 | "UserDataInclusion", 58 | "ReplDataInclusion", 59 | "UserQueryOutput", 60 | "UserByUsernameQueryOutput", 61 | "ReplQueryOutput", 62 | "GraphResponse", 63 | "User", 64 | "Repl", 65 | "Tag", 66 | "ReplComment", 67 | "UserSocial", 68 | "UserSocialType", 69 | "UserRole", 70 | "ReplOwner", 71 | "ReplCommentConnection", 72 | "CurrentUserDataInclusion", 73 | "EditorPreferences", 74 | "CurrentUser", 75 | "CurrentUserQueryOutput" 76 | ] 77 | }, 78 | { 79 | "name": "session", 80 | "description": "The session api provides you with information on the current user's coding session in the workspace.", 81 | "typeNames": ["OnActiveFileChangeListener", "DisposerFunction"] 82 | }, 83 | { 84 | "name": "themes", 85 | "description": "The themes api allows you to access the current user's theme and utilize the color tokens accordingly.", 86 | "typeNames": [ 87 | "ThemeValuesGlobal", 88 | "ColorScheme", 89 | "CustomTheme", 90 | "ThemeSyntaxHighlightingTag", 91 | "ThemeSyntaxHighlightingModifier", 92 | "ThemeEditorSyntaxHighlighting", 93 | "ThemeValuesEditor", 94 | "ThemeValues", 95 | "ThemeVersion", 96 | "DisposerFunction", 97 | "OnThemeChangeListener" 98 | ] 99 | }, 100 | { 101 | "name": "me", 102 | "description": "The `me` api module exposes information specific to the current extension.", 103 | "typeNames": [] 104 | }, 105 | { 106 | "name": "exec", 107 | "description": "The `exec` api module allows you to execute arbitrary shell commands.", 108 | "typeNames": [ 109 | "ExecResult", 110 | "SpawnOptions", 111 | "SpawnOutput", 112 | "SpawnResult", 113 | "SplitStderrSpawnOptions", 114 | "CombinedStderrSpawnOptions", 115 | "BaseSpawnOptions", 116 | "OutputStrCallback" 117 | ] 118 | }, 119 | { 120 | "name": "editor", 121 | "description": "The `editor` api module allows you to get the current user's editor preferences.", 122 | "experimental": true, 123 | "typeNames": ["EditorPreferences"] 124 | }, 125 | { 126 | "name": "auth", 127 | "description": "The `auth` api module allows you to securely authenticate a Replit user if they use your extension.", 128 | "experimental": true, 129 | "typeNames": [ 130 | "VerifyResult", 131 | "AuthenticateResult", 132 | "AuthenticatedUser", 133 | "AuthenticateResult", 134 | "JWTPayload", 135 | "JWTHeaderParameters", 136 | "JWTVerifyResult" 137 | ] 138 | }, 139 | { 140 | "name": "debug", 141 | "description": "The `debug` api module allows you to log data to the Extension Devtools", 142 | "typeNames": ["Primitive", "ObjectType", "NumericindexType", "Data"] 143 | }, 144 | { 145 | "name": "commands", 146 | "description": "The `commands` api module allows you to register commands that can be run from the CLUI command bar and other contribution points.", 147 | "typeNames": [ 148 | "CommandFnArgs", 149 | "CommandsFn", 150 | "CreateCommand", 151 | "Run", 152 | "SerializableValue", 153 | "BaseCommandArgs", 154 | "ActionCommandArgs", 155 | "ContextCommandArgs", 156 | "CommandArgs", 157 | "CommandProxy", 158 | "AddCommandArgs" 159 | ] 160 | } 161 | ] 162 | -------------------------------------------------------------------------------- /modules/extensions/src/types/themes.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./data"; 2 | 3 | /** 4 | * Alias for strings 5 | */ 6 | export type CssColor = string; 7 | 8 | /** 9 | * Global theme values interface 10 | */ 11 | export interface ThemeValuesGlobal { 12 | __typename?: string; 13 | backgroundRoot: CssColor; 14 | backgroundDefault: CssColor; 15 | backgroundHigher: CssColor; 16 | backgroundHighest: CssColor; 17 | backgroundOverlay: CssColor; 18 | foregroundDefault: CssColor; 19 | foregroundDimmer: CssColor; 20 | foregroundDimmest: CssColor; 21 | outlineDimmest: CssColor; 22 | outlineDimmer: CssColor; 23 | outlineDefault: CssColor; 24 | outlineStronger: CssColor; 25 | outlineStrongest: CssColor; 26 | accentPrimaryDimmest: CssColor; 27 | accentPrimaryDimmer: CssColor; 28 | accentPrimaryDefault: CssColor; 29 | accentPrimaryStronger: CssColor; 30 | accentPrimaryStrongest: CssColor; 31 | accentPositiveDimmest: CssColor; 32 | accentPositiveDimmer: CssColor; 33 | accentPositiveDefault: CssColor; 34 | accentPositiveStronger: CssColor; 35 | accentPositiveStrongest: CssColor; 36 | accentNegativeDimmest: CssColor; 37 | accentNegativeDimmer: CssColor; 38 | accentNegativeDefault: CssColor; 39 | accentNegativeStronger: CssColor; 40 | accentNegativeStrongest: CssColor; 41 | redDimmest: CssColor; 42 | redDimmer: CssColor; 43 | redDefault: CssColor; 44 | redStronger: CssColor; 45 | redStrongest: CssColor; 46 | orangeDimmest: CssColor; 47 | orangeDimmer: CssColor; 48 | orangeDefault: CssColor; 49 | orangeStronger: CssColor; 50 | orangeStrongest: CssColor; 51 | yellowDimmest: CssColor; 52 | yellowDimmer: CssColor; 53 | yellowDefault: CssColor; 54 | yellowStronger: CssColor; 55 | yellowStrongest: CssColor; 56 | limeDimmest: CssColor; 57 | limeDimmer: CssColor; 58 | limeDefault: CssColor; 59 | limeStronger: CssColor; 60 | limeStrongest: CssColor; 61 | greenDimmest: CssColor; 62 | greenDimmer: CssColor; 63 | greenDefault: CssColor; 64 | greenStronger: CssColor; 65 | greenStrongest: CssColor; 66 | tealDimmest: CssColor; 67 | tealDimmer: CssColor; 68 | tealDefault: CssColor; 69 | tealStronger: CssColor; 70 | tealStrongest: CssColor; 71 | blueDimmest: CssColor; 72 | blueDimmer: CssColor; 73 | blueDefault: CssColor; 74 | blueStronger: CssColor; 75 | blueStrongest: CssColor; 76 | blurpleDimmest: CssColor; 77 | blurpleDimmer: CssColor; 78 | blurpleDefault: CssColor; 79 | blurpleStronger: CssColor; 80 | blurpleStrongest: CssColor; 81 | purpleDimmest: CssColor; 82 | purpleDimmer: CssColor; 83 | purpleDefault: CssColor; 84 | purpleStronger: CssColor; 85 | purpleStrongest: CssColor; 86 | magentaDimmest: CssColor; 87 | magentaDimmer: CssColor; 88 | magentaDefault: CssColor; 89 | magentaStronger: CssColor; 90 | magentaStrongest: CssColor; 91 | pinkDimmest: CssColor; 92 | pinkDimmer: CssColor; 93 | pinkDefault: CssColor; 94 | pinkStronger: CssColor; 95 | pinkStrongest: CssColor; 96 | greyDimmest: CssColor; 97 | greyDimmer: CssColor; 98 | greyDefault: CssColor; 99 | greyStronger: CssColor; 100 | greyStrongest: CssColor; 101 | brownDimmest: CssColor; 102 | brownDimmer: CssColor; 103 | brownDefault: CssColor; 104 | brownStronger: CssColor; 105 | brownStrongest: CssColor; 106 | black: CssColor; 107 | white: CssColor; 108 | } 109 | 110 | /** 111 | * Enumerated Color Scheme 112 | */ 113 | export enum ColorScheme { 114 | Light = "light", 115 | Dark = "dark", 116 | } 117 | 118 | /** 119 | * Custom Theme GraphQL type 120 | */ 121 | export interface CustomTheme { 122 | author: User; 123 | colorScheme: ColorScheme; 124 | hasUnpublishedChanges: boolean; 125 | id: number; 126 | isCurrentUserThemeAuthor: boolean; 127 | isInstalledByCurrentUser: boolean; 128 | latestThemeVersion: ThemeVersion; 129 | numInstalls?: number; 130 | slug?: string; 131 | status?: "public" | "private"; 132 | title?: string; 133 | } 134 | 135 | /** 136 | * Theme Syntax Highlighting Tag 137 | */ 138 | export interface ThemeSyntaxHighlightingTag { 139 | __typename: string; 140 | name: string; 141 | modifiers: null | Array; 142 | } 143 | 144 | /** 145 | * Theme Syntax Highlighting Modifier 146 | */ 147 | export interface ThemeSyntaxHighlightingModifier { 148 | textDecoration?: string; 149 | fontSize?: string; 150 | fontWeight?: string; 151 | fontStyle?: string; 152 | color?: string; 153 | } 154 | 155 | /** 156 | * Theme Editor Syntax Highlighting 157 | */ 158 | export interface ThemeEditorSyntaxHighlighting { 159 | __typename: string; 160 | tags: Array; 161 | values: ThemeSyntaxHighlightingModifier; 162 | } 163 | 164 | /** 165 | * Editor Theme Values, an array of ThemeEditorSyntaxHighlighting 166 | */ 167 | export interface ThemeValuesEditor { 168 | syntaxHighlighting: Array; 169 | } 170 | 171 | /** 172 | * Both global and editor theme values 173 | */ 174 | export interface ThemeValues { 175 | __typename?: string; 176 | editor: ThemeValuesEditor; 177 | global: ThemeValuesGlobal; 178 | } 179 | 180 | /** 181 | * Theme Version GraphQL type 182 | */ 183 | export interface ThemeVersion { 184 | __typename?: string; 185 | id: number; 186 | hue: number; 187 | lightness: number; 188 | saturation: number; 189 | timeUpdated?: string; 190 | description?: string; 191 | customTheme?: CustomTheme; 192 | values?: ThemeValues; 193 | } 194 | 195 | /** 196 | * Fires with the new theme values when the current theme changes 197 | */ 198 | export type OnThemeChangeValuesListener = (values: ThemeValuesGlobal) => void; 199 | 200 | /** 201 | * Fires with the new theme data when the current theme changes 202 | */ 203 | export type OnThemeChangeListener = (theme: ThemeVersion) => void; 204 | -------------------------------------------------------------------------------- /modules/extensions/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DirectoryChildNode, 3 | WatchFileListeners, 4 | WatchTextFileListeners, 5 | WatchDirListeners, 6 | } from "./fs"; 7 | import { 8 | UserDataInclusion, 9 | UserQueryOutput, 10 | UserByUsernameQueryOutput, 11 | ReplDataInclusion, 12 | ReplQueryOutput, 13 | CurrentUserQueryOutput, 14 | CurrentUserDataInclusion, 15 | EditorPreferences, 16 | } from "./data"; 17 | import { 18 | ThemeValuesGlobal, 19 | ThemeVersion, 20 | OnThemeChangeValuesListener, 21 | OnThemeChangeListener, 22 | } from "./themes"; 23 | import { OnActiveFileChangeListener } from "./session"; 24 | import Comlink from "comlink"; 25 | import { Data } from "../api/debug"; 26 | import { CommandFnArgs, CommandProxy, CreateCommand } from "../commands"; 27 | 28 | export * from "./fs"; 29 | export * from "./themes"; 30 | export * from "./data"; 31 | export * from "./session"; 32 | export * from "./exec"; 33 | export * from "./auth"; 34 | 35 | /** 36 | * An enumerated set of values for the Handshake between the workspace and an extension 37 | */ 38 | export enum HandshakeStatus { 39 | Ready = "ready", 40 | Error = "error", 41 | Loading = "loading", 42 | } 43 | 44 | /** 45 | * The Replit init() function arguments 46 | */ 47 | export interface ReplitInitArgs { 48 | timeout?: number; 49 | } 50 | 51 | /** 52 | * The output of the Replit init() function 53 | */ 54 | export interface ReplitInitOutput { 55 | dispose: () => void; 56 | status: HandshakeStatus; 57 | } 58 | 59 | /** 60 | * A cleanup/disposer function (void) 61 | */ 62 | export type DisposerFunction = () => void; 63 | 64 | /** 65 | * The Extension Port 66 | */ 67 | export type ExtensionPortAPI = { 68 | handshake: (handshakeArgs: { clientName: string; clientVersion: string }) => { 69 | success: true; 70 | }; 71 | 72 | // fs Module 73 | readFile: ( 74 | path: string, 75 | encoding: "utf8" | "binary" | null 76 | ) => Promise< 77 | | { content: string } 78 | | { 79 | error: string; 80 | } 81 | >; 82 | writeFile: ( 83 | path: string, 84 | content: string | Blob 85 | ) => Promise< 86 | | { success: boolean } 87 | | { 88 | error: string; 89 | } 90 | >; 91 | readDir: (path: string) => Promise<{ 92 | children: Array; 93 | error: string; 94 | }>; 95 | createDir: (path: string) => Promise<{ 96 | success: boolean; 97 | error: string | null; 98 | }>; 99 | deleteFile: (path: string) => Promise< 100 | | {} 101 | | { 102 | error: string; 103 | } 104 | >; 105 | deleteDir: (path: string) => Promise< 106 | | {} 107 | | { 108 | error: string; 109 | } 110 | >; 111 | move: ( 112 | path: string, 113 | to: string 114 | ) => Promise<{ 115 | success: boolean; 116 | error: string | null; 117 | }>; 118 | copyFile: ( 119 | path: string, 120 | to: string 121 | ) => Promise<{ 122 | success: boolean; 123 | error: string | null; 124 | }>; 125 | watchFile: ( 126 | path: string, 127 | watcher: WatchFileListeners, 128 | encoding: "utf8" | "binary" | null 129 | ) => DisposerFunction; 130 | watchTextFile: (path: string, watcher: WatchTextFileListeners) => () => void; 131 | watchDir: (path: string, watcher: WatchDirListeners) => DisposerFunction; 132 | 133 | // replDb Module 134 | setReplDbValue: (key: string, value: string) => Promise; 135 | getReplDbValue: (key: string) => 136 | | { 137 | error: string | null; 138 | } 139 | | string; 140 | listReplDbKeys: (prefix: string) => Promise< 141 | | { keys: string[] } 142 | | { 143 | error: string; 144 | } 145 | >; 146 | deleteReplDbKey: (key: string) => Promise; 147 | 148 | activatePane: () => Promise; 149 | 150 | // theme 151 | getCurrentThemeValues: () => Promise; 152 | onThemeChangeValues: ( 153 | callback: OnThemeChangeValuesListener 154 | ) => Promise; 155 | getCurrentTheme: () => Promise; 156 | onThemeChange: (callback: OnThemeChangeListener) => Promise; 157 | 158 | filePath: string; 159 | 160 | // messages Module 161 | showConfirm: (text: string, length?: number) => string; 162 | showError: (text: string, length?: number) => string; 163 | showNotice: (text: string, length?: number) => string; 164 | showWarning: (text: string, length?: number) => string; 165 | hideMessage: (id: string) => void; 166 | hideAllMessages: () => void; 167 | 168 | // data Module 169 | currentUser: (args: CurrentUserDataInclusion) => CurrentUserQueryOutput; 170 | userById: (args: { id: number } & UserDataInclusion) => UserQueryOutput; 171 | userByUsername: ( 172 | args: { username: string } & UserDataInclusion 173 | ) => UserByUsernameQueryOutput; 174 | currentRepl: (args: ReplDataInclusion) => ReplQueryOutput; 175 | replById: (args: { id: string } & ReplDataInclusion) => ReplQueryOutput; 176 | replByUrl: (args: { url: string } & ReplDataInclusion) => ReplQueryOutput; 177 | 178 | // session Module 179 | watchActiveFile: (callback: OnActiveFileChangeListener) => DisposerFunction; 180 | getActiveFile: () => Promise; 181 | 182 | commands: { 183 | registerCommand: (command: CommandProxy) => void; 184 | registerCreateCommand: ( 185 | data: { 186 | commandId: string; 187 | contributions: Array; 188 | }, 189 | create: (createArgs: CommandFnArgs) => Promise 190 | ) => void; 191 | }; 192 | 193 | experimental: ExperimentalAPI; 194 | internal: InternalAPI; 195 | debug: DebugAPI; 196 | 197 | exec: (args: { 198 | splitStderr?: boolean; 199 | args: Array; 200 | env?: { 201 | [key: string]: string; 202 | }; 203 | onOutput: (output: string) => void; 204 | onStdErr: (stderr: string) => void; 205 | onError: (error: string) => void; 206 | }) => Promise<{ 207 | dispose: () => void; 208 | promise: Promise<{ 209 | exitCode: number; 210 | error: string | null; 211 | }>; 212 | }>; 213 | }; 214 | 215 | export type ExperimentalAPI = { 216 | editor: { 217 | getPreferences: () => Promise; 218 | }; 219 | 220 | auth: { 221 | getAuthToken: () => Promise; 222 | }; 223 | }; 224 | 225 | export type DebugAPI = { 226 | info: (message: string, data?: Data) => Promise; 227 | warn: (message: string, data?: Data) => Promise; 228 | error: (message: string, data?: Data) => Promise; 229 | }; 230 | 231 | export type InternalAPI = {}; 232 | 233 | export type Promisify = T extends Promise ? T : Promise; 234 | 235 | export type RemoteProperty = T extends Function | Comlink.ProxyMarked 236 | ? Comlink.Remote 237 | : T extends object 238 | ? T 239 | : Promisify; // We don't want to promisify objects, but we do want to promisify all other primitives 240 | 241 | export type RemoteObject = { 242 | [P in keyof T]: RemoteProperty; 243 | }; 244 | 245 | export type ExtensionPort = RemoteObject; 246 | -------------------------------------------------------------------------------- /modules/tester/src/tests/fs.ts: -------------------------------------------------------------------------------- 1 | import { TestNamespace, TestObject } from "../types"; 2 | import { fs, FsNodeType } from "@replit/extensions"; 3 | import { assert, expect } from "chai"; 4 | import { assertDirExists, createTestDir, createTestFile } from "../utils/tests"; 5 | import { randomString } from "../utils/assertions"; 6 | 7 | const tests: TestObject = { 8 | "writeFile should create a new file, readFile should read its contents": 9 | async () => { 10 | const { dispose } = await createTestFile("extension_tester/readFile.txt"); 11 | 12 | // Cleanup 13 | dispose(); 14 | }, 15 | 16 | "createDir should create a new directory, readDir should list its children": 17 | async () => { 18 | const { dirName, dispose: removeDir } = await createTestDir( 19 | "extension_tester/createDir" 20 | ); 21 | const { fileName, dispose } = await createTestFile( 22 | `extension_tester/createDir/createDir.txt` 23 | ); 24 | 25 | // Read the directory to ensure the new directory exists 26 | const { children } = await assertDirExists(dirName); 27 | expect(children).to.deep.include({ 28 | filename: fileName.replace("extension_tester/createDir/", ""), 29 | type: FsNodeType.File, 30 | }); 31 | 32 | // Cleanup 33 | dispose(); 34 | removeDir(); 35 | }, 36 | 37 | "deleteFile should delete a file": async () => { 38 | const { fileName, dispose } = await createTestFile( 39 | "extension_tester/deleteFile.txt" 40 | ); 41 | 42 | await fs.deleteFile(fileName); 43 | 44 | // Read the directory to ensure the file doesn't exist 45 | const res = await fs.readDir("extension_tester"); 46 | expect(res.children).to.not.include({ 47 | filename: fileName.replace("extension_tester/", ""), 48 | type: FsNodeType.File, 49 | }); 50 | 51 | // Cleanup 52 | dispose(); 53 | }, 54 | 55 | "deleteDir should delete a directory": async () => { 56 | const { dispose } = await createTestDir("extension_tester/deleteDir"); 57 | 58 | // Cleanup 59 | dispose(); 60 | }, 61 | 62 | "move should move a file to the specified path": async () => { 63 | const { fileName, dispose } = await createTestFile( 64 | "extension_tester/move.txt" 65 | ); 66 | const { dirName, dispose: removeDir } = await createTestDir( 67 | "extension_tester/move" 68 | ); 69 | 70 | // Move the file 71 | const res = await fs.move( 72 | fileName, 73 | fileName.replace("extension_tester", dirName) 74 | ); 75 | assert.isTrue(res.success); 76 | 77 | // Test to see if the file is in the new dir 78 | const { children } = await assertDirExists(dirName); 79 | expect(children).to.deep.include({ 80 | filename: fileName.replace("extension_tester/", ""), 81 | type: FsNodeType.File, 82 | }); 83 | 84 | // Cleanup 85 | dispose(); 86 | removeDir(); 87 | }, 88 | 89 | "copyFile should copy a file to the specified path": async () => { 90 | const { fileName, dispose } = await createTestFile( 91 | "extension_tester/copyFile.txt" 92 | ); 93 | const { dirName, dispose: removeDir } = await createTestDir( 94 | "extension_tester/copyFile" 95 | ); 96 | 97 | // Copy the file 98 | const res = await fs.copyFile( 99 | fileName, 100 | fileName.replace("extension_tester", dirName) 101 | ); 102 | assert.isTrue(res.success); 103 | 104 | // Check to see if the file is in the new dir 105 | const { children } = await assertDirExists(dirName); 106 | expect(children).to.deep.include({ 107 | filename: fileName.replace("extension_tester/", ""), 108 | type: FsNodeType.File, 109 | }); 110 | 111 | // Cleanup 112 | dispose(); 113 | removeDir(); 114 | }, 115 | 116 | "watchFile should watch the contents of a file": async (log) => { 117 | const { fileName, dispose: disposeFile } = await createTestFile( 118 | "extension_tester/watchFile.txt", 119 | "Hello World" 120 | ); 121 | 122 | // Initialize the watcher 123 | const disposeWatcher = await fs.watchFile(fileName, { 124 | onChange: (change) => { 125 | log("Content updated: " + change); 126 | }, 127 | onError: () => { 128 | throw new Error("Failed to watch file"); 129 | }, 130 | onMoveOrDelete: () => { 131 | log("File moved or deleted"); 132 | throw new Error("File moved or deleted"); 133 | }, 134 | }); 135 | 136 | // Update the file 137 | for (let i = 0; i < 5; i++) { 138 | await fs.writeFile(fileName, randomString()); 139 | } 140 | 141 | // Cleanup 142 | disposeWatcher(); 143 | disposeFile(); 144 | }, 145 | 146 | "watchDir should watch the children in a directory": async (log) => { 147 | const { dirName, dispose: removeDir } = await createTestDir( 148 | "extension_tester/watchDir" 149 | ); 150 | 151 | // Initialize the watcher 152 | const disposeWatcher = await fs.watchDir(dirName, { 153 | onChange: (children) => { 154 | log( 155 | "Children Updated: " + 156 | children.map((child) => child.path.split("/").at(-1)).join(", ") 157 | ); 158 | }, 159 | onError: () => { 160 | throw new Error("Failed to watch directory"); 161 | }, 162 | onMoveOrDelete: () => { 163 | log("Directory moved or deleted"); 164 | throw new Error("Directory moved or deleted"); 165 | }, 166 | }); 167 | 168 | // Create test files 169 | for (let i = 0; i < 5; i++) { 170 | const { dispose } = await createTestFile( 171 | "extension_tester/watchDir/file.txt" 172 | ); 173 | 174 | setTimeout(dispose, 2000); 175 | } 176 | 177 | // Cleanup 178 | disposeWatcher(); 179 | removeDir(); 180 | }, 181 | 182 | "watchTextFile should watch the contents of a text file": async (log) => { 183 | const { fileName, dispose: disposeFile } = await createTestFile( 184 | "extension_tester/watchTextFile.txt", 185 | "Hello World" 186 | ); 187 | 188 | // Iniitialize the watcher 189 | const disposeWatcher = fs.watchTextFile(fileName, { 190 | onChange: (change) => { 191 | log(`Watched file updated with ${change.changes.length} OTs.`); 192 | }, 193 | onError: () => { 194 | throw new Error("Failed to watch file"); 195 | }, 196 | onMoveOrDelete: () => { 197 | log("File moved or deleted"); 198 | throw new Error("File moved or deleted"); 199 | }, 200 | onReady: ({ initialContent }) => { 201 | log(`File Watcher ready with initial content "${initialContent}"`); 202 | 203 | writeChanges(); 204 | }, 205 | }); 206 | 207 | // Update the watched file + Cleanup 208 | const writeChanges = async () => { 209 | for (let _ of new Array(5)) { 210 | await fs.writeFile(fileName, randomString()); 211 | } 212 | 213 | disposeWatcher(); 214 | disposeFile(); 215 | }; 216 | }, 217 | }; 218 | 219 | const FsTests: TestNamespace = { 220 | module: "fs", 221 | tests, 222 | }; 223 | 224 | export default FsTests; 225 | -------------------------------------------------------------------------------- /modules/extensions/src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { ProxyMarked } from "comlink"; 2 | import { proxy } from "../util/comlink"; 3 | 4 | /** 5 | * Surfaces that a command can appear in. 6 | */ 7 | export enum ContributionType { 8 | CommandBar = "commandbar", 9 | FiletreeContextMenu = "filetree-context-menu", 10 | SidebarSearch = "sidebar-search", 11 | EditorContextMenu = "editor-context-menu", 12 | } 13 | 14 | export type CommandFnArgs = { 15 | /** 16 | * Whether the command is currently active. That is, the user has selected this command in the command bar. 17 | * 18 | * Subcommands are computed even if the command is not active, so that the command bar can show people helpful suggestions of what they can do next 19 | */ 20 | active: boolean; 21 | 22 | /** 23 | * The current search query. This is the text that the user has typed into the command bar. 24 | */ 25 | search: string; 26 | 27 | /** 28 | * The current path. This is the path of commands that the user has selected in the command bar. 29 | * 30 | * The first element of the array is the "root" which contains contextual information about the command. It varies depending on the surface. 31 | */ 32 | path: SerializableValue[]; 33 | }; 34 | 35 | export type CommandsFn = ( 36 | args: CommandFnArgs 37 | ) => Promise>; 38 | 39 | export type CreateCommand = ( 40 | args: CommandFnArgs 41 | ) => 42 | | CommandProxy 43 | | Promise 44 | | CommandArgs 45 | | Promise 46 | | null; 47 | 48 | export type Run = () => any; 49 | 50 | export type SerializableValue = 51 | | string 52 | | number 53 | | boolean 54 | | null 55 | | undefined 56 | | SerializableValue[] 57 | | { [key: string]: SerializableValue }; 58 | 59 | export type BaseCommandArgs = { 60 | /** 61 | * The command's label. This is the primary text that represents your command in the Commandbar. 62 | */ 63 | label: string; 64 | 65 | /** 66 | * The command's description. This is the secondary text that appears next to the label in the Commandbar. 67 | */ 68 | description?: string; 69 | 70 | /** 71 | * The command's icon. This is a relative path to the icon that will be displayed next to the label in the Commandbar. 72 | * 73 | * For example, if `public` is your statically served directory and you have an icon at `./public/icons/cmd.svg`, you would set this to `/icons/cmd.svg` 74 | */ 75 | icon?: string; 76 | }; 77 | 78 | export type ActionCommandArgs = BaseCommandArgs & { 79 | /** 80 | * The function that will be called when someone choses to run this command. 81 | */ 82 | run: Run; 83 | }; 84 | 85 | export type ContextCommandArgs = BaseCommandArgs & { 86 | /** 87 | * The function that will be called to compute 'subcommands' for this command. The subcommands can be any number of commands, 88 | * and even computed dynamically in response to the different arguments passed to this function or some other external state. 89 | */ 90 | commands: CommandsFn; 91 | }; 92 | 93 | export type CommandArgs = ActionCommandArgs | ContextCommandArgs; 94 | 95 | /** 96 | * This validates a CommandArgs object. We make sure that exactly one of `commands` or `run` is defined, and every other argument is serializable. 97 | */ 98 | function validateCommandArgs(cmdArgs: unknown): asserts cmdArgs is CommandArgs { 99 | // Make sure cmdArgs is object 100 | if (typeof cmdArgs !== "object") { 101 | throw new Error("Command arguments must be an object"); 102 | } 103 | if (cmdArgs === null) { 104 | throw new Error("Command arguments must not be null"); 105 | } 106 | 107 | // Make sure it contains `commands` or `run` 108 | if (!("commands" in cmdArgs) && !("run" in cmdArgs)) { 109 | throw new Error("One of `commands` or `run` must be defined"); 110 | } 111 | 112 | // But not both 113 | if ( 114 | "commands" in cmdArgs && 115 | cmdArgs.commands && 116 | "run" in cmdArgs && 117 | cmdArgs.run 118 | ) { 119 | throw new Error("Only one of `commands` or `run` must be defined"); 120 | } 121 | 122 | // And when provided, they must always be a function 123 | if ("commands" in cmdArgs && typeof cmdArgs.commands !== "function") { 124 | throw new Error("`commands` must be a function"); 125 | } 126 | 127 | if ("run" in cmdArgs && typeof cmdArgs.run !== "function") { 128 | throw new Error("`run` must be a function"); 129 | } 130 | 131 | // Make sure all other arguments are serializable 132 | for (let entry of Object.entries(cmdArgs)) { 133 | if (entry[0] === "commands" || entry[0] === "run") { 134 | continue; 135 | } 136 | 137 | try { 138 | JSON.stringify({ [entry[0]]: entry[1] }); 139 | } catch (e) { 140 | throw new Error(`Command argument '${entry[0]}' is not serializable`); 141 | } 142 | } 143 | } 144 | 145 | export const CommandSymbol = Symbol("Command"); 146 | 147 | export function isCommandProxy(cmd: object): cmd is CommandProxy { 148 | return CommandSymbol in cmd && cmd[CommandSymbol] === true; 149 | } 150 | 151 | /** 152 | * This function validates a command and wraps it in a proxy so that it can be sent over the wire 153 | * 154 | * It: 155 | * - Validates the command's arguments, separates serializable and non-serializable arguments 156 | * - Wraps the command in a proxy so that it can be sent over the wire 157 | * - Wraps the command's `commands` function to ensure that all subcommands are also Command() wrapped 158 | * - Adds a symbol to the command to identify a wrapped command 159 | */ 160 | export function Command(cmdArgs: CommandArgs): CommandProxy { 161 | // If the command is already wrapped, just return it. 162 | // This is to prevent accidental double-wrapping 163 | if (isCommandProxy(cmdArgs)) { 164 | throw new Error("Command is already wrapped"); 165 | } 166 | 167 | // Validate the command's arguments 168 | validateCommandArgs(cmdArgs); 169 | 170 | if ("commands" in cmdArgs) { 171 | const { commands, ...props } = cmdArgs; 172 | 173 | let cmdProxy: CommandProxy = proxy({ 174 | data: { 175 | ...props, 176 | type: "context", 177 | }, 178 | commands: async (args: CommandFnArgs) => { 179 | // Compute subcommands 180 | let subCmds = await commands(args); 181 | 182 | // While we expect commands() to return an array, we don't want to throw an error if it doesn't. 183 | if (!subCmds || !Array.isArray(subCmds)) { 184 | return proxy([]); 185 | } 186 | 187 | const commandProxyArray: Array = subCmds.map((subCmd) => { 188 | // Subcommands can be either a CommandArgs or a CommandProxy. 189 | // If it's already a wrapped command, just return it. 190 | if (isCommandProxy(subCmd)) { 191 | return subCmd; 192 | } 193 | 194 | // Otherwise, wrap it in Command() 195 | return Command(subCmd); 196 | }); 197 | 198 | // Return the subcommands as a proxy array 199 | return proxy(commandProxyArray); 200 | }, 201 | 202 | // Attach CommandSymbol to identify a wrapped command 203 | [CommandSymbol]: true, 204 | }); 205 | 206 | return cmdProxy; 207 | } else { 208 | const { run, ...props } = cmdArgs; 209 | 210 | let cmdProxy: CommandProxy = proxy({ 211 | data: { 212 | ...props, 213 | type: "action", 214 | }, 215 | run, 216 | // Attach CommandSymbol to identify a wrapped command 217 | [CommandSymbol]: true, 218 | }); 219 | 220 | return cmdProxy; 221 | } 222 | } 223 | 224 | export type CommandProxy = 225 | | ({ 226 | data: { 227 | type: "action"; 228 | label: string; 229 | description?: string; 230 | icon?: string; 231 | }; 232 | run?: Run; 233 | } & ProxyMarked & { [CommandSymbol]: true }) 234 | | ({ 235 | data: { 236 | type: "context"; 237 | label: string; 238 | description?: string; 239 | icon?: string; 240 | }; 241 | commands?: (args: CommandFnArgs) => Promise>; 242 | } & ProxyMarked & { [CommandSymbol]: true }); 243 | -------------------------------------------------------------------------------- /modules/extensions/src/auth/ed25519.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a polyfill for ed25519 support in the browser, which is currently not available as part of the 3 | * WebCrypto API. The polyfill code is vendored from https://www.npmjs.com/package/@yoursunny/webcrypto-ed25519, 4 | * and modified to use a modern and more secure version of @noble/curves. 5 | */ 6 | 7 | import { ed25519 as ed } from "@noble/curves/ed25519"; 8 | import { bytesToHex } from "@noble/curves/abstract/utils"; 9 | import * as asn1 from "@root/asn1"; 10 | // @ts-expect-error no typing 11 | import { toBase64Url as b64encode, toBuffer as b64decode } from "b64u-lite"; 12 | 13 | export const C = { 14 | wicgAlgorithm: "Ed25519", 15 | nodeAlgorithm: "NODE-ED25519", 16 | nodeNamedCurve: "NODE-ED25519", 17 | kty: "OKP", 18 | crv: "Ed25519", 19 | oid: "2B6570".toLowerCase(), 20 | }; 21 | 22 | export function isEd25519Algorithm(a: any) { 23 | return ( 24 | a === C.wicgAlgorithm || 25 | a === C.nodeAlgorithm || 26 | a.name === C.wicgAlgorithm || 27 | (a.name === C.nodeAlgorithm && a.namedCurve === C.nodeNamedCurve) 28 | ); 29 | } 30 | 31 | export const Ed25519Algorithm: KeyAlgorithm = { 32 | name: C.wicgAlgorithm, 33 | }; 34 | 35 | function asUint8Array(b: BufferSource): Uint8Array { 36 | if (b instanceof Uint8Array) { 37 | return b; 38 | } 39 | if (b instanceof ArrayBuffer) { 40 | return new Uint8Array(b); 41 | } 42 | return new Uint8Array(b.buffer, b.byteOffset, b.byteLength); 43 | } 44 | 45 | function asArrayBuffer(b: Uint8Array): ArrayBuffer { 46 | if (b.byteLength === b.buffer.byteLength) { 47 | return b.buffer; 48 | } 49 | return b.buffer.slice(b.byteOffset, b.byteLength); 50 | } 51 | 52 | const slot = "8d9df0f7-1363-4d2c-8152-ce4ed78f27d8"; 53 | 54 | interface Ed25519CryptoKey extends CryptoKey { 55 | [slot]: Uint8Array; 56 | } 57 | 58 | class Ponyfill implements Record { 59 | constructor(private readonly super_: SubtleCrypto) { 60 | this.orig_ = {} as any; 61 | for (const method of [ 62 | "generateKey", 63 | "exportKey", 64 | "importKey", 65 | "encrypt", 66 | "decrypt", 67 | "wrapKey", 68 | "unwrapKey", 69 | "deriveBits", 70 | "deriveKey", 71 | "sign", 72 | "verify", 73 | "digest", 74 | ] as const) { 75 | if (this[method]) { 76 | this.orig_[method] = super_[method]; 77 | } else { 78 | this[method] = super_[method].bind(super_) as any; 79 | } 80 | } 81 | } 82 | 83 | private readonly orig_: Record; 84 | 85 | public async generateKey( 86 | algorithm: KeyAlgorithm, 87 | extractable: boolean, 88 | keyUsages: Iterable 89 | ): Promise { 90 | if (isEd25519Algorithm(algorithm)) { 91 | const pvt = ed.utils.randomPrivateKey(); 92 | const pub = await ed.getPublicKey(pvt); 93 | 94 | const usages = Array.from(keyUsages); 95 | const privateKey: Ed25519CryptoKey = { 96 | algorithm, 97 | extractable, 98 | type: "private", 99 | usages, 100 | [slot]: pvt, 101 | }; 102 | const publicKey: Ed25519CryptoKey = { 103 | algorithm, 104 | extractable: true, 105 | type: "public", 106 | usages, 107 | [slot]: pub, 108 | }; 109 | return { privateKey, publicKey }; 110 | } 111 | return this.orig_.generateKey.apply(this.super_, arguments); 112 | } 113 | 114 | public async exportKey( 115 | format: KeyFormat, 116 | key: CryptoKey 117 | ): Promise { 118 | if (isEd25519Algorithm(key.algorithm) && key.extractable) { 119 | const raw = (key as Ed25519CryptoKey)[slot]; 120 | switch (format) { 121 | case "jwk": { 122 | const jwk: JsonWebKey = { 123 | kty: C.kty, 124 | crv: C.crv, 125 | }; 126 | if (key.type === "public") { 127 | jwk.x = b64encode(raw); 128 | } else { 129 | jwk.d = b64encode(raw); 130 | jwk.x = b64encode(await ed.getPublicKey(raw)); 131 | } 132 | return jwk; 133 | } 134 | case "spki": { 135 | return asArrayBuffer( 136 | asn1.pack([ 137 | "30", 138 | [ 139 | ["30", [["06", "2B6570"]]], 140 | ["03", raw], 141 | ], 142 | ]) 143 | ); 144 | } 145 | } 146 | } 147 | return this.orig_.exportKey.apply(this.super_, arguments); 148 | } 149 | 150 | public async importKey( 151 | format: KeyFormat, 152 | keyData: JsonWebKey | BufferSource, 153 | algorithm: Algorithm, 154 | extractable: boolean, 155 | keyUsages: Iterable 156 | ): Promise { 157 | if (isEd25519Algorithm(algorithm)) { 158 | const usages = Array.from(keyUsages); 159 | switch (format) { 160 | case "jwk": { 161 | const jwk = keyData as JsonWebKey; 162 | if (jwk.kty !== C.kty || jwk.crv !== C.crv || !jwk.x) { 163 | break; 164 | } 165 | 166 | const key: Ed25519CryptoKey = { 167 | algorithm, 168 | extractable, 169 | type: jwk.d ? "private" : "public", 170 | usages, 171 | [slot]: b64decode(jwk.d ?? jwk.x), 172 | }; 173 | return key; 174 | } 175 | case "spki": { 176 | const der = asn1.parseVerbose(asUint8Array(keyData as BufferSource)); 177 | const algo = der.children?.[0]?.children?.[0]?.value; 178 | const raw = der.children?.[1]?.value; 179 | if ( 180 | !(algo instanceof Uint8Array) || 181 | bytesToHex(algo) !== C.oid || 182 | !(raw instanceof Uint8Array) 183 | ) { 184 | break; 185 | } 186 | const key: Ed25519CryptoKey = { 187 | algorithm, 188 | extractable: true, 189 | type: "public", 190 | usages, 191 | [slot]: raw, 192 | }; 193 | return key; 194 | } 195 | } 196 | } 197 | return this.orig_.importKey.apply(this.super_, arguments); 198 | } 199 | 200 | public async sign( 201 | algorithm: AlgorithmIdentifier, 202 | key: CryptoKey, 203 | data: BufferSource 204 | ): Promise { 205 | if ( 206 | isEd25519Algorithm(algorithm) && 207 | isEd25519Algorithm(key.algorithm) && 208 | key.type === "private" && 209 | key.usages.includes("sign") 210 | ) { 211 | return asArrayBuffer( 212 | await ed.sign(asUint8Array(data), (key as Ed25519CryptoKey)[slot]) 213 | ); 214 | } 215 | return this.orig_.sign.apply(this.super_, arguments); 216 | } 217 | 218 | public async verify( 219 | algorithm: AlgorithmIdentifier, 220 | key: CryptoKey, 221 | signature: BufferSource, 222 | data: BufferSource 223 | ): Promise { 224 | if ( 225 | isEd25519Algorithm(algorithm) && 226 | isEd25519Algorithm(key.algorithm) && 227 | key.type === "public" && 228 | key.usages.includes("verify") 229 | ) { 230 | const s = asUint8Array(signature); 231 | const m = asUint8Array(data); 232 | const p = (key as Ed25519CryptoKey)[slot]; 233 | return ed.verify(s, m, p); 234 | } 235 | return this.orig_.verify.apply(this.super_, arguments); 236 | } 237 | } 238 | interface Ponyfill extends Record {} 239 | 240 | export function ponyfillEd25519(subtle = crypto.subtle): SubtleCrypto | null { 241 | if (!subtle) { 242 | // This is for JSDOM compatibility, since that environment doesn't support the crypto.subtle API at all. 243 | // It shouldn't happen on a real browser 244 | console.warn(`polyfill ed25519: crypto.subtle is not available`); 245 | return null; 246 | } 247 | 248 | return new Ponyfill(subtle) as unknown as SubtleCrypto; 249 | } 250 | 251 | export function polyfillEd25519(): boolean { 252 | const ponyfill = ponyfillEd25519(); 253 | 254 | if (!ponyfill) { 255 | return false; 256 | } 257 | 258 | Object.defineProperty(globalThis.crypto, "subtle", { 259 | value: ponyfill, 260 | configurable: true, 261 | }); 262 | 263 | return true; 264 | } 265 | -------------------------------------------------------------------------------- /modules/extensions/src/api/fs/watching.ts: -------------------------------------------------------------------------------- 1 | import { ChangeSet, ChangeSpec, Text } from "@codemirror/state"; 2 | import { extensionPort, proxy } from "../../util/comlink"; 3 | import { TextChange, WatchTextFileListeners } from "../../types"; 4 | 5 | /** 6 | * A helper to change a ChangeSet into a simpler serializable & human readable format 7 | */ 8 | function changeSetToSimpleTextChange(changes: ChangeSet): Array { 9 | const simpleChanges: Array = []; 10 | 11 | changes.iterChanges((fromA, toA, _fromB, _toB, text) => { 12 | const change: TextChange = { from: fromA }; 13 | 14 | if (toA > fromA) { 15 | change.to = toA; 16 | } 17 | 18 | if (text.length) { 19 | change.insert = text.sliceString(0); 20 | } 21 | 22 | simpleChanges.push(change); 23 | }); 24 | 25 | return simpleChanges; 26 | } 27 | 28 | /** 29 | * watches a file via comlink, notifies listeners about changes. 30 | * it handles synchronization between local and remote text states. 31 | * properly disposes resources when no longer needed. 32 | */ 33 | class TextFileWatcher { 34 | /* 35 | * TODO: what do we do with out of order messages, postMessage has no guarantees of order 36 | * TODO: we need versioning to guarantee correctness. Related to above, using async/await doesn't guarantee that our change got applied before the next incoming change and vice versa 37 | */ 38 | private state: { 39 | localText: Text; 40 | remoteText: Text; 41 | unconfirmedChanges: Set<{ changes: ChangeSet }>; 42 | requestWriteChange: (changes: ChangeSpec) => Promise; 43 | } | null; 44 | private isDisposed: boolean; 45 | public dispose: () => void; 46 | 47 | constructor( 48 | private path: string, 49 | private listeners: { 50 | onReady: () => void; 51 | onChange: NonNullable; 52 | onMoveOrDelete: NonNullable; 53 | onError: NonNullable; 54 | } 55 | ) { 56 | this.state = null; 57 | this.isDisposed = false; 58 | this.dispose = () => { 59 | this.isDisposed = true; 60 | }; 61 | 62 | if (!extensionPort) { 63 | throw new Error("Expected extensionPort"); 64 | } 65 | 66 | extensionPort 67 | .watchTextFile( 68 | this.path, 69 | proxy({ 70 | onReady: this.handleReady.bind(this) as any, // wrongly typed at extensionPort 71 | onChange: this.handleChange.bind(this), 72 | onMoveOrDelete: (event) => { 73 | listeners.onMoveOrDelete(event); 74 | }, 75 | onError: (error) => { 76 | listeners.onError(error); 77 | }, 78 | }) 79 | ) 80 | .then((portDispose) => { 81 | if (this.isDisposed) { 82 | portDispose(); 83 | 84 | return; 85 | } 86 | 87 | this.dispose = () => { 88 | this.isDisposed = true; 89 | portDispose(); 90 | }; 91 | }); 92 | } 93 | 94 | public writeChange(changes: ChangeSpec) { 95 | if (this.isDisposed) { 96 | throw new Error("Wrote change on a disposed TextFileWatcher"); 97 | } 98 | 99 | if (!this.state) { 100 | throw new Error("Tried to write changes before ready"); 101 | } 102 | 103 | const changeSet = ChangeSet.of(changes, this.state.localText.length); 104 | this.state.localText = changeSet.apply(this.state.localText); 105 | 106 | this.enqueueChangeSet(changeSet); 107 | } 108 | 109 | public getLatestContent() { 110 | if (this.isDisposed) { 111 | throw new Error("Cannot get content of a disposed TextFileWatcher"); 112 | } 113 | 114 | if (!this.state) { 115 | throw new Error("Called getLatestContent on an unready TextFileWatcher"); 116 | } 117 | 118 | return this.state.localText.sliceString(0); 119 | } 120 | 121 | public getIsReady() { 122 | if (this.isDisposed) { 123 | throw new Error("Cannot get isReady of a disposed TextFileWatcher"); 124 | } 125 | 126 | return Boolean(this.state); 127 | } 128 | 129 | private async handleReady({ 130 | writeChange, 131 | initialContent, 132 | }: { 133 | writeChange: (changes: ChangeSpec) => Promise; 134 | initialContent: Promise; 135 | }) { 136 | if (this.isDisposed) { 137 | return; 138 | } 139 | 140 | const content = Text.of((await initialContent).split("\n")); 141 | this.state = { 142 | requestWriteChange: writeChange, 143 | localText: content, 144 | remoteText: content, 145 | unconfirmedChanges: new Set(), 146 | }; 147 | 148 | this.listeners.onReady(); 149 | } 150 | 151 | private handleChange({ changes: changeJSON }: { changes: any }) { 152 | if (this.isDisposed) { 153 | return; 154 | } 155 | 156 | if (!this.state) { 157 | throw new Error("unexpected handleOnChange called before handleOnReady"); 158 | } 159 | 160 | let changes = ChangeSet.fromJSON(changeJSON); 161 | 162 | this.state.remoteText = changes.apply(this.state.remoteText); 163 | 164 | for (const unconfirmed of this.state.unconfirmedChanges) { 165 | const unconfirmedUpdated = unconfirmed.changes.map(changes); 166 | changes = changes.map(unconfirmed.changes, true); 167 | unconfirmed.changes = unconfirmedUpdated; 168 | } 169 | 170 | this.state.localText = changes.apply(this.state.localText); 171 | 172 | this.listeners.onChange({ 173 | changes: changeSetToSimpleTextChange(changes), 174 | latestContent: this.getLatestContent(), 175 | }); 176 | } 177 | 178 | private async enqueueChangeSet(changes: ChangeSet) { 179 | if (this.isDisposed) { 180 | throw new Error("Wrote change on a disposed TextFileWatcher"); 181 | } 182 | 183 | if (!this.state) { 184 | throw new Error("Tried to write changes before ready"); 185 | } 186 | 187 | // Store in a ref since the ChangeSet is immutable, and it will change when fastfowarded 188 | const ref = { changes }; 189 | this.state.unconfirmedChanges.add(ref); 190 | await this.state.requestWriteChange( 191 | changeSetToSimpleTextChange(ref.changes) 192 | ); 193 | 194 | this.state.unconfirmedChanges.delete(ref); 195 | this.state.remoteText = ref.changes.apply(this.state.remoteText); 196 | } 197 | } 198 | 199 | /** 200 | * A class that manages multiple `TextFileWatcher` instances 201 | * ensuring that there's only one watcher per file to make sure 202 | * we are handling synchronization properly, having multiple watchers 203 | * will cause issues with the `TextFileWatcher` implementation. 204 | * Notifies listeners when a file is ready or when there are changes. 205 | * Automatically disposes watchers when there are no more listeners. 206 | * This should be a singleton, but it's not enforced for testability. 207 | */ 208 | class FileWatcherManager { 209 | private files: Map< 210 | string, 211 | { 212 | listeners: Set; 213 | watcher: TextFileWatcher; 214 | } 215 | >; 216 | 217 | constructor() { 218 | this.files = new Map(); 219 | } 220 | 221 | public watch(path: string, listeners: WatchTextFileListeners) { 222 | if (this.files.has(path)) { 223 | this.watchExisting(path, listeners); 224 | } else { 225 | this.watchNew(path, listeners); 226 | } 227 | 228 | return () => { 229 | const file = this.files.get(path); 230 | 231 | if (!file) { 232 | return; 233 | } 234 | 235 | file.listeners.delete(listeners); 236 | if (file.listeners.size === 0) { 237 | this.dispose(path); 238 | } 239 | }; 240 | } 241 | 242 | private dispose(path: string) { 243 | const file = this.files.get(path); 244 | 245 | if (!file) { 246 | return; 247 | } 248 | 249 | file.watcher.dispose(); 250 | this.files.delete(path); 251 | } 252 | 253 | private watchNew(path: string, listeners: WatchTextFileListeners) { 254 | const watcher = new TextFileWatcher(path, { 255 | onReady: () => { 256 | this.handleReady(path); 257 | }, 258 | onChange: (changeEvent) => { 259 | this.handleChange(path, changeEvent); 260 | }, 261 | onMoveOrDelete: (event) => { 262 | this.handleMoveOrDelete(path, event); 263 | }, 264 | onError: (error) => { 265 | this.handleError(path, error); 266 | }, 267 | }); 268 | 269 | this.files.set(path, { 270 | listeners: new Set([listeners]), 271 | watcher, 272 | }); 273 | } 274 | 275 | private watchExisting(path: string, listeners: WatchTextFileListeners) { 276 | const file = this.files.get(path); 277 | 278 | if (!file) { 279 | throw new Error("file is not watched"); 280 | } 281 | 282 | file.listeners.add(listeners); 283 | } 284 | 285 | private handleChange( 286 | path: string, 287 | changeEvent: Parameters>[0] 288 | ) { 289 | const file = this.files.get(path); 290 | 291 | if (!file) { 292 | throw new Error("Unexpected change on a non-watched file"); 293 | } 294 | 295 | if (!file.watcher.getIsReady()) { 296 | throw new Error("Unexpected change on a non-ready file"); 297 | } 298 | 299 | for (const { onChange } of file.listeners) { 300 | if (!onChange) { 301 | continue; 302 | } 303 | 304 | onChange(changeEvent); 305 | } 306 | } 307 | 308 | private handleReady(path: string) { 309 | const file = this.files.get(path); 310 | 311 | if (!file) { 312 | throw new Error("Unexpected change on a non-watched file"); 313 | } 314 | 315 | if (!file.watcher.getIsReady()) { 316 | throw new Error("Got ready on a non-ready file :/"); 317 | } 318 | 319 | const initialContent = file.watcher.getLatestContent(); 320 | for (const { onReady, onChange } of file.listeners) { 321 | onReady({ 322 | initialContent, 323 | getLatestContent: () => file.watcher.getLatestContent(), 324 | writeChange: (changes: TextChange | Array) => { 325 | file.watcher.writeChange(changes); 326 | 327 | for (const { onChange: otherOnChange } of file.listeners) { 328 | if (onChange === otherOnChange) { 329 | // we don't want to notify the originator, they already know about the change 330 | continue; 331 | } 332 | 333 | if (!otherOnChange) { 334 | continue; 335 | } 336 | 337 | otherOnChange({ 338 | changes: Array.isArray(changes) ? changes : [changes], 339 | latestContent: file.watcher.getLatestContent(), 340 | }); 341 | } 342 | }, 343 | }); 344 | } 345 | } 346 | 347 | private handleError(path: string, error: string) { 348 | const file = this.files.get(path); 349 | 350 | if (!file) { 351 | throw new Error("Unexpected error on a non-watched file"); 352 | } 353 | 354 | for (const { onError } of file.listeners) { 355 | if (!onError) { 356 | continue; 357 | } 358 | 359 | onError(error); 360 | } 361 | 362 | this.dispose(path); 363 | } 364 | 365 | private handleMoveOrDelete( 366 | path: string, 367 | event: Parameters>[0] 368 | ) { 369 | const file = this.files.get(path); 370 | 371 | if (!file) { 372 | throw new Error("Unexpected move or delete event on a non-watched file"); 373 | } 374 | 375 | for (const { onMoveOrDelete } of file.listeners) { 376 | if (!onMoveOrDelete) { 377 | continue; 378 | } 379 | 380 | onMoveOrDelete(event); 381 | } 382 | 383 | this.dispose(path); 384 | } 385 | } 386 | 387 | export const fileWatcherManager = new FileWatcherManager(); 388 | --------------------------------------------------------------------------------