diff --git a/.yarn/patches/monaco-editor-npm-0.52.2-584d16bfa6.patch b/.yarn/patches/monaco-editor-npm-0.52.2-584d16bfa6.patch new file mode 100644 index 0000000000..1c70398608 --- /dev/null +++ b/.yarn/patches/monaco-editor-npm-0.52.2-584d16bfa6.patch @@ -0,0 +1,10 @@ +diff --git a/esm/vs/base/common/marked/marked.js b/esm/vs/base/common/marked/marked.js +index 56333ffeb738907f144d15c6ebe44494d1b13330..d743087aa2640d6e3c629907a16cf67f3bb1a498 100644 +--- a/esm/vs/base/common/marked/marked.js ++++ b/esm/vs/base/common/marked/marked.js +@@ -2534,5 +2534,3 @@ export var setOptions = (__marked_exports.setOptions || exports.setOptions); + export var use = (__marked_exports.use || exports.use); + export var walkTokens = (__marked_exports.walkTokens || exports.walkTokens); + // ESM-uncomment-end +- +-//# sourceMappingURL=marked.umd.js.map diff --git a/README.md b/README.md index bd9554c076..0155a1d1ec 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/snaps-jest`](packages/snaps-jest) - [`@metamask/snaps-rollup-plugin`](packages/snaps-rollup-plugin) - [`@metamask/snaps-rpc-methods`](packages/snaps-rpc-methods) +- [`@metamask/snaps-sandbox`](packages/snaps-sandbox) - [`@metamask/snaps-sdk`](packages/snaps-sdk) - [`@metamask/snaps-simulation`](packages/snaps-simulation) - [`@metamask/snaps-utils`](packages/snaps-utils) @@ -40,12 +41,14 @@ linkStyle default opacity:0.5 snaps_jest(["@metamask/snaps-jest"]); snaps_rollup_plugin(["@metamask/snaps-rollup-plugin"]); snaps_rpc_methods(["@metamask/snaps-rpc-methods"]); + snaps_sandbox(["@metamask/snaps-sandbox"]); snaps_sdk(["@metamask/snaps-sdk"]); snaps_simulation(["@metamask/snaps-simulation"]); snaps_utils(["@metamask/snaps-utils"]); snaps_webpack_plugin(["@metamask/snaps-webpack-plugin"]); create_snap --> snaps_utils; snaps_browserify_plugin --> snaps_utils; + snaps_cli --> snaps_sandbox; snaps_cli --> snaps_sdk; snaps_cli --> snaps_utils; snaps_cli --> snaps_webpack_plugin; diff --git a/eslint.config.mjs b/eslint.config.mjs index 8d53698e43..2c5c54ff6a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -325,6 +325,7 @@ const config = createConfig([ 'packages/snaps-controllers/src/services/iframe/**/*', 'packages/snaps-controllers/src/services/webworker/**/*', 'packages/snaps-execution-environments/src/**/*', + 'packages/snaps-sandbox/src/**/*', 'packages/test-snaps/src/**/*', '**/*.test.browser.ts', ], diff --git a/package.json b/package.json index bfeeec07bf..d1a5e6d0f0 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose", "build:ci": "ts-bridge --project tsconfig.build.json --verbose", + "build:ci:post": "yarn build:sandbox", "build:examples": "yarn workspace @metamask/example-snaps build", "build:execution-environments": "yarn workspace @metamask/snaps-execution-environments build:lavamoat", - "build:post": "yarn build:examples && yarn build:execution-environments", + "build:post": "yarn build:examples && yarn build:execution-environments && yarn build:sandbox", + "build:sandbox": "yarn workspace @metamask/snaps-sandbox build", "changelog:update": "yarn workspaces foreach --all --parallel --interlaced --verbose run changelog:update", "changelog:validate": "yarn workspaces foreach --all --parallel --interlaced --verbose run changelog:validate", "child-workspace-package-names-as-json": "ts-node scripts/child-workspace-package-names-as-json.ts", diff --git a/packages/snaps-cli/CHANGELOG.md b/packages/snaps-cli/CHANGELOG.md index b2b36912b6..242053cfe1 100644 --- a/packages/snaps-cli/CHANGELOG.md +++ b/packages/snaps-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `sandbox` command to run sandbox tool ([#3306](https://github.com/MetaMask/snaps/pull/3306)) + - This command allows you to run the Snaps sandbox tool, which is useful for + quickly testing and debugging Snaps. + ## [7.0.0] ### Changed diff --git a/packages/snaps-cli/README.md b/packages/snaps-cli/README.md index d9379e6552..48184dff95 100644 --- a/packages/snaps-cli/README.md +++ b/packages/snaps-cli/README.md @@ -21,6 +21,7 @@ Commands: mm-snap build Build snap from source [aliases: b] mm-snap eval Attempt to evaluate snap bundle in SES [aliases: e] mm-snap manifest Validate the snap.manifest.json file [aliases: m] + mm-snap sandbox Start a sandbox server to interact with the Snap mm-snap serve Locally serve Snap file(s) for testing [aliases: s] mm-snap watch Build Snap on change [aliases: w] diff --git a/packages/snaps-cli/package.json b/packages/snaps-cli/package.json index 9f955077df..44828bf7e1 100644 --- a/packages/snaps-cli/package.json +++ b/packages/snaps-cli/package.json @@ -64,6 +64,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@metamask/snaps-sandbox": "workspace:^", "@metamask/snaps-sdk": "workspace:^", "@metamask/snaps-utils": "workspace:^", "@metamask/snaps-webpack-plugin": "workspace:^", @@ -79,6 +80,7 @@ "crypto-browserify": "^3.12.0", "domain-browser": "^4.22.0", "events": "^3.3.0", + "express": "^5.1.0", "fork-ts-checker-webpack-plugin": "^9.0.2", "https-browserify": "^1.0.0", "ora": "^5.4.1", @@ -89,7 +91,6 @@ "querystring-es3": "^0.2.1", "readable-stream": "^3.6.2", "semver": "^7.5.4", - "serve-handler": "^6.1.5", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", "string_decoder": "^1.3.0", @@ -111,9 +112,9 @@ "@metamask/auto-changelog": "^5.0.1", "@swc/jest": "^0.2.26", "@ts-bridge/cli": "^0.6.1", + "@types/express": "^5.0.1", "@types/jest": "^27.5.1", "@types/node": "18.14.2", - "@types/serve-handler": "^6.1.0", "@types/yargs": "^17.0.24", "cross-fetch": "^3.1.5", "deepmerge": "^4.2.2", diff --git a/packages/snaps-cli/src/builders.ts b/packages/snaps-cli/src/builders.ts index 61034587ef..55a5fc381d 100644 --- a/packages/snaps-cli/src/builders.ts +++ b/packages/snaps-cli/src/builders.ts @@ -6,6 +6,12 @@ const builders = { type: 'boolean', }, + build: { + describe: 'Build the Snap bundle', + type: 'boolean', + default: true, + }, + config: { alias: 'c', describe: 'Path to config file', diff --git a/packages/snaps-cli/src/commands/build/build.ts b/packages/snaps-cli/src/commands/build/build.ts index 94de1ef689..8e76ddf6c3 100644 --- a/packages/snaps-cli/src/commands/build/build.ts +++ b/packages/snaps-cli/src/commands/build/build.ts @@ -10,15 +10,17 @@ import type { Steps } from '../../utils'; import { success, executeSteps, info } from '../../utils'; import { evaluate } from '../eval'; -type BuildContext = { +export type BuildContext = { analyze: boolean; + build: boolean; config: ProcessedConfig; port?: number; }; -const steps: Steps = [ +export const steps: Steps = [ { name: 'Checking the input file.', + condition: ({ build }) => build, task: async ({ config }) => { const { input } = config; @@ -31,7 +33,8 @@ const steps: Steps = [ }, { name: 'Building the snap bundle.', - task: async ({ analyze, config, spinner }) => { + condition: ({ build }) => build, + task: async ({ analyze, build: enableBuild, config, spinner }) => { // We don't evaluate the bundle here, because it's done in a separate // step. const compiler = await build(config, { @@ -43,6 +46,7 @@ const steps: Steps = [ if (analyze) { return { analyze, + build: enableBuild, config, spinner, port: await getBundleAnalyzerPort(compiler), @@ -54,7 +58,7 @@ const steps: Steps = [ }, { name: 'Evaluating the snap bundle.', - condition: ({ config }) => config.evaluate, + condition: ({ build, config }) => build && config.evaluate, task: async ({ config, spinner }) => { const path = pathResolve( process.cwd(), @@ -94,6 +98,7 @@ export async function buildHandler( analyze = false, ): Promise { return await executeSteps(steps, { + build: true, config, analyze, }); diff --git a/packages/snaps-cli/src/commands/build/index.ts b/packages/snaps-cli/src/commands/build/index.ts index 60058a97ae..5ff0d6a966 100644 --- a/packages/snaps-cli/src/commands/build/index.ts +++ b/packages/snaps-cli/src/commands/build/index.ts @@ -16,4 +16,5 @@ const command = { }; export * from './implementation'; +export { steps } from './build'; export default command; diff --git a/packages/snaps-cli/src/commands/index.ts b/packages/snaps-cli/src/commands/index.ts index 3a210c7a7b..d6a9b6898f 100644 --- a/packages/snaps-cli/src/commands/index.ts +++ b/packages/snaps-cli/src/commands/index.ts @@ -1,6 +1,7 @@ import buildCommand from './build'; import evaluateCommand from './eval'; import manifestCommand from './manifest'; +import sandboxCommand from './sandbox'; import serveCommand from './serve'; import watchCommand from './watch'; @@ -8,6 +9,7 @@ const commands = [ buildCommand, evaluateCommand, manifestCommand, + sandboxCommand, serveCommand, watchCommand, ]; diff --git a/packages/snaps-cli/src/commands/sandbox/index.test.ts b/packages/snaps-cli/src/commands/sandbox/index.test.ts new file mode 100644 index 0000000000..c17adc2555 --- /dev/null +++ b/packages/snaps-cli/src/commands/sandbox/index.test.ts @@ -0,0 +1,19 @@ +import command from '.'; +import { sandboxHandler } from './sandbox'; +import { getMockConfig } from '../../test-utils'; +import type { YargsArgs } from '../../types/yargs'; + +jest.mock('./sandbox'); + +const getMockArgv = () => { + return { + context: { config: getMockConfig() }, + } as unknown as YargsArgs; +}; + +describe('sandbox command', () => { + it('calls the `sandboxHandler` function', async () => { + await command.handler(getMockArgv()); + expect(sandboxHandler).toHaveBeenCalled(); + }); +}); diff --git a/packages/snaps-cli/src/commands/sandbox/index.ts b/packages/snaps-cli/src/commands/sandbox/index.ts new file mode 100644 index 0000000000..bce1ebb59b --- /dev/null +++ b/packages/snaps-cli/src/commands/sandbox/index.ts @@ -0,0 +1,17 @@ +import type yargs from 'yargs'; + +import { sandboxHandler } from './sandbox'; +import builders from '../../builders'; +import type { YargsArgs } from '../../types/yargs'; + +const command = { + command: ['sandbox'], + desc: 'Start a sandbox server to interact with the Snap', + builder: (yarg: yargs.Argv) => { + yarg.option('build', builders.build); + }, + handler: async (argv: YargsArgs) => + sandboxHandler(argv.context.config, { build: argv.build }), +}; + +export default command; diff --git a/packages/snaps-cli/src/commands/sandbox/sandbox.test.ts b/packages/snaps-cli/src/commands/sandbox/sandbox.test.ts new file mode 100644 index 0000000000..334f9d74f9 --- /dev/null +++ b/packages/snaps-cli/src/commands/sandbox/sandbox.test.ts @@ -0,0 +1,61 @@ +import { getMockConfig } from '@metamask/snaps-cli/test-utils'; +import { DEFAULT_SNAP_BUNDLE } from '@metamask/snaps-utils/test-utils'; +import fs from 'fs'; + +import { sandboxHandler } from './sandbox'; +import { build } from '../build'; + +jest.mock('fs'); +jest.mock('./server', () => ({ + startSandbox: jest.fn().mockResolvedValue({ port: 8080 }), +})); +jest.mock('../build/implementation'); +jest.mock('../eval'); + +describe('sandboxHandler', () => { + it('builds the Snap if the build option is `true`', async () => { + await fs.promises.writeFile('/input.js', DEFAULT_SNAP_BUNDLE); + + jest.spyOn(console, 'log').mockImplementation(); + const config = getMockConfig({ + input: '/input.js', + output: { + path: '/foo', + filename: 'output.js', + }, + }); + + await sandboxHandler(config, {}); + + expect(process.exitCode).not.toBe(1); + expect(build).toHaveBeenCalledWith(config, { + analyze: false, + evaluate: false, + spinner: expect.any(Object), + }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Sandbox running at http://localhost:8080.'), + ); + }); + + it('does not build the Snap if the build option is `false`', async () => { + jest.spyOn(console, 'log').mockImplementation(); + const config = getMockConfig({ + input: '/input.js', + output: { + path: '/foo', + filename: 'output.js', + }, + }); + + await sandboxHandler(config, { build: false }); + + expect(process.exitCode).not.toBe(1); + expect(build).not.toHaveBeenCalled(); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Sandbox running at http://localhost:8080.'), + ); + }); +}); diff --git a/packages/snaps-cli/src/commands/sandbox/sandbox.ts b/packages/snaps-cli/src/commands/sandbox/sandbox.ts new file mode 100644 index 0000000000..49eea388f2 --- /dev/null +++ b/packages/snaps-cli/src/commands/sandbox/sandbox.ts @@ -0,0 +1,43 @@ +import { startSandbox } from './server'; +import type { ProcessedConfig } from '../../config'; +import type { Steps } from '../../utils'; +import { success, executeSteps } from '../../utils'; +import { steps as buildSteps } from '../build'; +import type { BuildContext } from '../build/build'; + +type SandboxOptions = { + build?: boolean; +}; + +type SandboxContext = BuildContext; + +const steps: Steps = [ + ...buildSteps, + { + name: 'Running sandbox.', + task: async ({ config, spinner }) => { + const { port } = await startSandbox(config); + success(`Sandbox running at http://localhost:${port}.`, spinner); + + spinner.stop(); + }, + }, +]; + +/** + * Start the sandbox. + * + * @param config - The config object. + * @param options - The options object. + * @param options.build - Whether to build the Snap before starting the sandbox. + */ +export async function sandboxHandler( + config: ProcessedConfig, + { build = true }: SandboxOptions, +) { + await executeSteps(steps, { + analyze: false, + build, + config, + }); +} diff --git a/packages/snaps-cli/src/commands/sandbox/server.test.ts b/packages/snaps-cli/src/commands/sandbox/server.test.ts new file mode 100644 index 0000000000..f04a9f8fa3 --- /dev/null +++ b/packages/snaps-cli/src/commands/sandbox/server.test.ts @@ -0,0 +1,42 @@ +import { getMockConfig } from '@metamask/snaps-cli/test-utils'; +import fs from 'fs'; +import { dirname } from 'path'; + +import { startSandbox } from './server'; + +jest.mock('fs'); + +describe('startSandbox', () => { + beforeAll(async () => { + const sandboxPath = require.resolve( + '@metamask/snaps-sandbox/dist/index.html', + ); + + await fs.promises.mkdir(dirname(sandboxPath), { recursive: true }); + await fs.promises.writeFile( + sandboxPath, + 'Snaps Sandbox', + ); + }); + + it('starts the sandbox server', async () => { + const config = getMockConfig({ + server: { + port: 8080, + }, + }); + + const { port, close } = await startSandbox(config); + + expect(port).toBe(8080); + expect(close).toBeInstanceOf(Function); + + const response = await fetch('http://localhost:8080'); + expect(response.status).toBe(200); + + const text = await response.text(); + expect(text).toBe('Snaps Sandbox'); + + await close(); + }); +}); diff --git a/packages/snaps-cli/src/commands/sandbox/server.ts b/packages/snaps-cli/src/commands/sandbox/server.ts new file mode 100644 index 0000000000..0d67fbc2f8 --- /dev/null +++ b/packages/snaps-cli/src/commands/sandbox/server.ts @@ -0,0 +1,34 @@ +import { static as expressStatic } from 'express'; +import { dirname } from 'path'; + +import type { ProcessedConfig } from '../../config'; +import { getServer } from '../../webpack'; + +/** + * Start the sandbox. + * + * @param config - The config object. + * @returns The server instance. + */ +export async function startSandbox(config: ProcessedConfig) { + const server = getServer(config, [ + (app) => { + app.use( + '/__sandbox__', + expressStatic( + dirname(require.resolve('@metamask/snaps-sandbox/dist/index.html')), + ), + ); + + app.get('/', (_request, response) => { + response.sendFile( + require.resolve('@metamask/snaps-sandbox/dist/index.html'), + ); + }); + }, + ]); + + // If the `configPort` is `0`, the OS will choose a random port for us, so we + // need to get the port from the server after it starts. + return await server.listen(config.server.port); +} diff --git a/packages/snaps-cli/src/types/yargs.d.ts b/packages/snaps-cli/src/types/yargs.d.ts index 5362dcded2..b3ec09f966 100644 --- a/packages/snaps-cli/src/types/yargs.d.ts +++ b/packages/snaps-cli/src/types/yargs.d.ts @@ -21,6 +21,7 @@ type YargsArgs = { }; analyze?: boolean; + build?: boolean; fix?: boolean; input?: string; diff --git a/packages/snaps-cli/src/webpack/server.test.ts b/packages/snaps-cli/src/webpack/server.test.ts index bad04f8981..cf1cae39aa 100644 --- a/packages/snaps-cli/src/webpack/server.test.ts +++ b/packages/snaps-cli/src/webpack/server.test.ts @@ -1,8 +1,7 @@ import { getSnapManifest } from '@metamask/snaps-utils/test-utils'; import fetch from 'cross-fetch'; import { promises as fs } from 'fs'; -import http, { IncomingMessage, Server, ServerResponse } from 'http'; -import serveMiddleware from 'serve-handler'; +import { Server } from 'http'; import { getAllowedPaths, getServer } from './server'; import { getMockConfig } from '../test-utils'; @@ -201,32 +200,6 @@ describe('getServer', () => { await close(); }); - it('calls the serve middleware for allowed files', async () => { - const config = getMockConfig({ - input: 'src/index.js', - server: { - root: '/foo', - port: 0, - }, - }); - - const server = getServer(config); - const { port, close } = await server.listen(); - - const response = await fetch(`http://localhost:${port}/snap.manifest.json`); - - expect(response.status).toBe(200); - expect(await response.text()).toBe(''); - - expect(serveMiddleware).toHaveBeenCalledWith( - expect.any(IncomingMessage), - expect.any(ServerResponse), - expect.objectContaining({ public: expect.stringContaining('foo') }), - ); - - await close(); - }); - it('ignores query strings', async () => { const config = getMockConfig({ input: 'src/index.js', @@ -244,13 +217,7 @@ describe('getServer', () => { ); expect(response.status).toBe(200); - expect(await response.text()).toBe(''); - - expect(serveMiddleware).toHaveBeenCalledWith( - expect.any(IncomingMessage), - expect.any(ServerResponse), - expect.objectContaining({ public: expect.stringContaining('foo') }), - ); + expect(await response.text()).toBe(JSON.stringify(getSnapManifest())); await close(); }); @@ -267,11 +234,10 @@ describe('getServer', () => { const server = getServer(config); const { port, close } = await server.listen(); - const response = await fetch(`http://localhost:${port}/`); + const response = await fetch(`http://localhost:${port}/.env`); expect(response.status).toBe(404); expect(await response.text()).toBe(''); - expect(serveMiddleware).not.toHaveBeenCalled(); await close(); }); @@ -281,21 +247,19 @@ describe('getServer', () => { input: 'src/index.js', server: { root: '/foo', - port: 0, + port: 13490, }, }); - const createServer = jest.spyOn(http, 'createServer'); - const server = getServer(config); - const httpServer: Server = createServer.mock.results[0].value; - - jest.spyOn(httpServer, 'listen').mockImplementationOnce(() => { - throw new Error('Address already in use.'); - }); + const firstServer = getServer(config); + const { close } = await firstServer.listen(); - await expect(server.listen()).rejects.toThrow('Address already in use.'); + const secondServer = getServer(config); + await expect(secondServer.listen()).rejects.toThrow( + 'listen EADDRINUSE: address already in use :::13490', + ); - httpServer.close(); + await close(); }); it('throws if the server fails to close', async () => { @@ -307,18 +271,15 @@ describe('getServer', () => { }, }); - const createServer = jest.spyOn(http, 'createServer'); const server = getServer(config); - const httpServer: Server = createServer.mock.results[0].value; + const { server: httpServer, close } = await server.listen(); // @ts-expect-error - Invalid mock. jest.spyOn(httpServer, 'close').mockImplementationOnce((callback) => { return callback?.(new Error('Failed to close server.')); }); - const { close } = await server.listen(); await expect(close()).rejects.toThrow('Failed to close server.'); - httpServer.close(); }); }); diff --git a/packages/snaps-cli/src/webpack/server.ts b/packages/snaps-cli/src/webpack/server.ts index b59d7faf07..557ec5be5f 100644 --- a/packages/snaps-cli/src/webpack/server.ts +++ b/packages/snaps-cli/src/webpack/server.ts @@ -1,14 +1,10 @@ import type { SnapManifest } from '@metamask/snaps-utils'; -import { - logError, - NpmSnapFileNames, - readJsonFile, -} from '@metamask/snaps-utils/node'; -import type { IncomingMessage, Server, ServerResponse } from 'http'; -import { createServer } from 'http'; +import { NpmSnapFileNames, readJsonFile } from '@metamask/snaps-utils/node'; +import type { Express, Request } from 'express'; +import express, { static as expressStatic } from 'express'; +import type { Server } from 'http'; import type { AddressInfo } from 'net'; import { join, relative, resolve as resolvePath, sep, posix } from 'path'; -import serveMiddleware from 'serve-handler'; import type { ProcessedConfig } from '../config'; @@ -85,6 +81,26 @@ export function getAllowedPaths( ]; } +/** + * Get whether the request path is allowed. This is used to check if the request + * path is in the list of allowed paths for the static server. + * + * @param request - The request object. + * @param config - The config object. + * @returns A promise that resolves to `true` if the path is allowed, or + * `false` if it is not. + */ +async function isAllowedPath(request: Request, config: ProcessedConfig) { + const manifestPath = join(config.server.root, NpmSnapFileNames.Manifest); + const { result } = await readJsonFile(manifestPath); + const allowedPaths = getAllowedPaths(config, result); + + const path = request.path.slice(1); + return allowedPaths.some((allowedPath) => path === allowedPath); +} + +type Middleware = (app: Express) => void; + /** * Get a static server for development purposes. * @@ -93,71 +109,48 @@ export function getAllowedPaths( * difficult to customize. * * @param config - The config object. + * @param middleware - An array of middleware functions to run before serving + * the static files. * @returns An object with a `listen` method that returns a promise that * resolves when the server is listening. */ -export function getServer(config: ProcessedConfig) { - /** - * Get the response for a request. This is extracted into a function so that - * we can easily catch errors and send a 500 response. - * - * @param request - The request. - * @param response - The response. - * @returns A promise that resolves when the response is sent. - */ - async function getResponse( - request: IncomingMessage, - response: ServerResponse, - ) { - const manifestPath = join(config.server.root, NpmSnapFileNames.Manifest); - const { result } = await readJsonFile(manifestPath); - const allowedPaths = getAllowedPaths(config, result); - - const pathname = - request.url && - request.headers.host && - new URL(request.url, `http://${request.headers.host}`).pathname; - const path = pathname?.slice(1); - const allowed = allowedPaths.some((allowedPath) => path === allowedPath); - - if (!allowed) { - response.statusCode = 404; - response.end(); - return; - } - - await serveMiddleware(request, response, { - public: config.server.root, - directoryListing: false, - headers: [ - { - source: '**/*', - headers: [ - { - key: 'Cache-Control', - value: 'no-cache', - }, - { - key: 'Access-Control-Allow-Origin', - value: '*', - }, - ], - }, - ], - }); - } - - const server = createServer((request, response) => { - getResponse(request, response).catch( - /* istanbul ignore next */ - (error) => { - logError(error); - response.statusCode = 500; +export function getServer( + config: ProcessedConfig, + middleware: Middleware[] = [], +) { + const app = express(); + + // Run "middleware" functions before serving the static files. + middleware.forEach((fn) => fn(app)); + + // Check for allowed paths in the request URL. + app.use((request, response, next) => { + isAllowedPath(request, config) + .then((allowed) => { + if (allowed) { + // eslint-disable-next-line promise/no-callback-in-promise + next(); + return; + } + + response.status(404); response.end(); - }, - ); + }) + // eslint-disable-next-line promise/no-callback-in-promise + .catch(next); }); + // Serve the static files. + app.use( + expressStatic(config.server.root, { + dotfiles: 'deny', + setHeaders: (res) => { + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Access-Control-Allow-Origin', '*'); + }, + }), + ); + /** * Start the server on the port specified in the config. * @@ -173,26 +166,27 @@ export function getServer(config: ProcessedConfig) { server: Server; close: () => Promise; }>((resolve, reject) => { - try { - server.listen(port, () => { - const close = async () => { - await new Promise((resolveClose, rejectClose) => { - server.close((closeError) => { - if (closeError) { - return rejectClose(closeError); - } - - return resolveClose(); - }); + // eslint-disable-next-line consistent-return + const server = app.listen(port, (error) => { + if (error) { + return reject(error); + } + + const close = async () => { + await new Promise((resolveClose, rejectClose) => { + server.close((closeError) => { + if (closeError) { + return rejectClose(closeError); + } + + return resolveClose(); }); - }; - - const address = server.address() as AddressInfo; - resolve({ port: address.port, server, close }); - }); - } catch (listenError) { - reject(listenError); - } + }); + }; + + const address = server.address() as AddressInfo; + resolve({ port: address.port, server, close }); + }); }); }; diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 93ce742a10..a0ceb018ad 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -128,7 +128,7 @@ "@types/readable-stream": "^4.0.15", "@types/semver": "^7.5.0", "@types/tar-stream": "^3.1.1", - "@vitest/browser": "^3.0.8", + "@vitest/browser": "^3.1.1", "deepmerge": "^4.2.2", "depcheck": "^1.4.7", "eslint": "^9.11.0", @@ -146,7 +146,7 @@ "vite": "^6.2.6", "vite-plugin-node-polyfills": "^0.23.0", "vite-tsconfig-paths": "^4.0.5", - "vitest": "^3.0.8" + "vitest": "^3.1.1" }, "peerDependencies": { "@metamask/snaps-execution-environments": "workspace:^" diff --git a/packages/snaps-execution-environments/package.json b/packages/snaps-execution-environments/package.json index 2b59bb53a0..546e37c130 100644 --- a/packages/snaps-execution-environments/package.json +++ b/packages/snaps-execution-environments/package.json @@ -91,11 +91,10 @@ "@swc/jest": "^0.2.26", "@testing-library/dom": "^10.4.0", "@ts-bridge/cli": "^0.6.1", - "@types/express": "^4.17.17", "@types/jest": "^27.5.1", "@types/node": "18.14.2", - "@vitest/browser": "^3.0.8", - "@vitest/coverage-v8": "^3.0.8", + "@vitest/browser": "^3.1.1", + "@vitest/coverage-v8": "^3.1.1", "babel-plugin-tsconfig-paths-module-resolver": "^1.0.4", "babelify": "^10.0.0", "browserify": "^17.0.0", @@ -120,7 +119,7 @@ "typescript": "~5.3.3", "vite": "^6.2.6", "vite-tsconfig-paths": "^4.0.5", - "vitest": "^3.0.8", + "vitest": "^3.1.1", "yargs": "^17.7.1" }, "engines": { diff --git a/packages/snaps-jest/package.json b/packages/snaps-jest/package.json index e66bc34b16..454134fac6 100644 --- a/packages/snaps-jest/package.json +++ b/packages/snaps-jest/package.json @@ -65,7 +65,7 @@ "@metamask/snaps-simulation": "workspace:^", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.4.0", - "express": "^4.21.2", + "express": "^5.1.0", "jest-environment-node": "^29.5.0", "jest-matcher-utils": "^29.5.0", "redux": "^4.2.1" diff --git a/packages/snaps-jest/src/internals/server.ts b/packages/snaps-jest/src/internals/server.ts index 21f204acbb..32aeadd94a 100644 --- a/packages/snaps-jest/src/internals/server.ts +++ b/packages/snaps-jest/src/internals/server.ts @@ -8,7 +8,6 @@ import { createModuleLogger } from '@metamask/utils'; import express, { static as expressStatic } from 'express'; import { promises as fs } from 'fs'; import type { Server } from 'http'; -import { createServer } from 'http'; import { resolve as pathResolve } from 'path'; import { rootLogger } from './logger'; @@ -79,15 +78,15 @@ export async function startServer(options: ServerOptions) { app.use(expressStatic(pathResolve(process.cwd(), options.root))); - const server = createServer(app); return await new Promise((resolve, reject) => { - server.listen(options.port, () => { - resolve(server); - }); + const server = app.listen(options.port, (error) => { + if (error) { + log(error); + reject(error); + return; + } - server.on('error', (error) => { - log(error); - reject(error); + resolve(server); }); }); } diff --git a/packages/snaps-sandbox/.depcheckrc.json b/packages/snaps-sandbox/.depcheckrc.json new file mode 100644 index 0000000000..ae5882c44f --- /dev/null +++ b/packages/snaps-sandbox/.depcheckrc.json @@ -0,0 +1,4 @@ +{ + "ignore-patterns": ["dist", "coverage"], + "ignores": ["@metamask/auto-changelog", "@vitest/coverage-v8", "typescript"] +} diff --git a/packages/snaps-sandbox/CHANGELOG.md b/packages/snaps-sandbox/CHANGELOG.md new file mode 100644 index 0000000000..720e00537e --- /dev/null +++ b/packages/snaps-sandbox/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/snaps/ diff --git a/packages/snaps-sandbox/LICENSE b/packages/snaps-sandbox/LICENSE new file mode 100644 index 0000000000..0e00d1b522 --- /dev/null +++ b/packages/snaps-sandbox/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2025 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/snaps-sandbox/index.html b/packages/snaps-sandbox/index.html new file mode 100644 index 0000000000..4debf5100c --- /dev/null +++ b/packages/snaps-sandbox/index.html @@ -0,0 +1,13 @@ + + + + + + + MetaMask Snaps Sandbox + + +
+ + + diff --git a/packages/snaps-sandbox/package.json b/packages/snaps-sandbox/package.json new file mode 100644 index 0000000000..f17b6ca823 --- /dev/null +++ b/packages/snaps-sandbox/package.json @@ -0,0 +1,80 @@ +{ + "name": "@metamask/snaps-sandbox", + "version": "0.0.0", + "description": "A sandbox tool for interacting with MetaMask Snaps", + "keywords": [ + "MetaMask", + "Snaps", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/snaps/tree/main/packages/snaps-sandbox#readme", + "bugs": { + "url": "https://github.com/MetaMask/snaps/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snaps.git" + }, + "license": "ISC", + "sideEffects": false, + "type": "module", + "exports": { + "./dist/index.html": { + "default": "./dist/index.html" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/snaps-sandbox", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn changelog:validate && yarn lint:dependencies", + "lint:ci": "yarn lint", + "lint:dependencies": "depcheck", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", + "lint:misc": "prettier --no-error-on-unmatched-pattern --log-level warn \"**/*.json\" \"**/*.md\" \"**/*.html\" \"!CHANGELOG.md\" --ignore-path ../../.gitignore", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "start": "vite", + "test": "vitest --silent passed-only", + "test:verbose": "vitest", + "test:watch": "vitest --watch" + }, + "devDependencies": { + "@chakra-ui/react": "^3.15.0", + "@emotion/react": "^11.14.0", + "@metamask/auto-changelog": "^5.0.1", + "@metamask/providers": "^22.0.0", + "@metamask/utils": "^11.4.0", + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.71.5", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.1.1", + "depcheck": "^1.4.7", + "eslint": "^9.11.0", + "fast-deep-equal": "^3.1.3", + "happy-dom": "^17.4.4", + "jotai": "^2.12.2", + "monaco-editor": "patch:monaco-editor@npm%3A0.52.2#~/.yarn/patches/monaco-editor-npm-0.52.2-584d16bfa6.patch", + "nanoid": "^3.3.10", + "prettier": "^3.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "typescript": "~5.3.3", + "vite": "^6.2.6", + "vitest": "^3.1.1" + }, + "engines": { + "node": "^18.16 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/snaps-sandbox/src/App.test.tsx b/packages/snaps-sandbox/src/App.test.tsx new file mode 100644 index 0000000000..072e2de402 --- /dev/null +++ b/packages/snaps-sandbox/src/App.test.tsx @@ -0,0 +1,14 @@ +import { waitFor } from '@testing-library/dom'; +import { act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { App } from './App'; +import { render } from './test-utils'; + +describe('App', () => { + it('renders the app component', async () => { + const { getByText } = await act(() => render()); + + await waitFor(() => expect(getByText('Request')).toBeInTheDocument()); + }); +}); diff --git a/packages/snaps-sandbox/src/App.tsx b/packages/snaps-sandbox/src/App.tsx new file mode 100644 index 0000000000..d4894bacc6 --- /dev/null +++ b/packages/snaps-sandbox/src/App.tsx @@ -0,0 +1,12 @@ +import { HStack } from '@chakra-ui/react'; +import type { FunctionComponent } from 'react'; + +import { Sandbox } from './components'; +import { Sidebar } from './features/sidebar'; + +export const App: FunctionComponent = () => ( + + + + +); diff --git a/packages/snaps-sandbox/src/assets/favicon.svg b/packages/snaps-sandbox/src/assets/favicon.svg new file mode 100644 index 0000000000..1b91e11c55 --- /dev/null +++ b/packages/snaps-sandbox/src/assets/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Bold-Italic.woff2 b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Bold-Italic.woff2 new file mode 100644 index 0000000000..838f26b8f8 Binary files /dev/null and b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Bold-Italic.woff2 differ diff --git a/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Bold.woff2 b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Bold.woff2 new file mode 100644 index 0000000000..877bb5d06f Binary files /dev/null and b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Bold.woff2 differ diff --git a/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Book-Italic.woff2 b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Book-Italic.woff2 new file mode 100644 index 0000000000..483d4063a9 Binary files /dev/null and b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Book-Italic.woff2 differ diff --git a/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Book.woff2 b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Book.woff2 new file mode 100644 index 0000000000..89dadb2f34 Binary files /dev/null and b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Book.woff2 differ diff --git a/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Medium-Italic.woff2 b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Medium-Italic.woff2 new file mode 100644 index 0000000000..96655b6913 Binary files /dev/null and b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Medium-Italic.woff2 differ diff --git a/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Medium.woff2 b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Medium.woff2 new file mode 100644 index 0000000000..a9d06f79e9 Binary files /dev/null and b/packages/snaps-sandbox/src/assets/fonts/CentraNo1-Medium.woff2 differ diff --git a/packages/snaps-sandbox/src/assets/fonts/MM-Sans-Variable.woff2 b/packages/snaps-sandbox/src/assets/fonts/MM-Sans-Variable.woff2 new file mode 100755 index 0000000000..ed303f6fd8 Binary files /dev/null and b/packages/snaps-sandbox/src/assets/fonts/MM-Sans-Variable.woff2 differ diff --git a/packages/snaps-sandbox/src/assets/fonts/fonts.css b/packages/snaps-sandbox/src/assets/fonts/fonts.css new file mode 100644 index 0000000000..1827ae0399 --- /dev/null +++ b/packages/snaps-sandbox/src/assets/fonts/fonts.css @@ -0,0 +1,47 @@ +@font-face { + font-family: 'MM Sans'; + font-weight: 1 1000; + src: url('./MM-Sans-Variable.woff2') format('woff2'); +} + +@font-face { + font-family: 'Centra No1'; + font-style: normal; + font-weight: 400; + src: url('./CentraNo1-Book.woff2') format('woff2'); +} + +@font-face { + font-family: 'Centra No1'; + font-style: italic; + font-weight: 400; + src: url('./CentraNo1-Book-Italic.woff2') format('woff2'); +} + +@font-face { + font-family: 'Centra No1'; + font-style: normal; + font-weight: 500; + src: url('./CentraNo1-Medium.woff2') format('woff2'); +} + +@font-face { + font-family: 'Centra No1'; + font-style: italic; + font-weight: 500; + src: url('./CentraNo1-Medium-Italic.woff2') format('woff2'); +} + +@font-face { + font-family: 'Centra No1'; + font-style: normal; + font-weight: 700; + src: url('./CentraNo1-Bold.woff2') format('woff2'); +} + +@font-face { + font-family: 'Centra No1'; + font-style: italic; + font-weight: 700; + src: url('./CentraNo1-Bold-Italic.woff2') format('woff2'); +} diff --git a/packages/snaps-sandbox/src/assets/fox.svg b/packages/snaps-sandbox/src/assets/fox.svg new file mode 100644 index 0000000000..32c6519347 --- /dev/null +++ b/packages/snaps-sandbox/src/assets/fox.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/snaps-sandbox/src/components/Editor.test.tsx b/packages/snaps-sandbox/src/components/Editor.test.tsx new file mode 100644 index 0000000000..694b118265 --- /dev/null +++ b/packages/snaps-sandbox/src/components/Editor.test.tsx @@ -0,0 +1,44 @@ +import type { EditorProps } from '@monaco-editor/react'; +import { useEffect } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { Editor } from './Editor'; +import { render } from '../test-utils'; + +const setDiagnosticsOptions = vi.hoisted(() => vi.fn()); + +vi.mock('monaco-editor'); +vi.mock('@monaco-editor/react', () => ({ + default: vi.fn(({ beforeMount }: EditorProps) => { + useEffect(() => { + beforeMount?.({ + languages: { + json: { + // @ts-expect-error: Partial mock. + jsonDefaults: { + setDiagnosticsOptions, + }, + }, + }, + }); + }, []); + + return