Enable short URLs in the playground (#6383)

## Summary

This PR adds a [Workers
KV](https://developers.cloudflare.com/workers/runtime-apis/kv/)-based
database to the playground, which enables us to associate shared
snippets with a stable ID, which in turn allows us to generate short
URLs, rather than our existing extremely-long URLs.

For now, the URLs are based on UUID, so they look like
https://play.ruff.rs/a1c40d58-f643-4a3e-bc23-15021e16acef. (This URL
isn't expected to work, as the playground isn't deployed; it's only
included as an example.)

There are no visible changes in the UI here -- you still click the
"Share" button, which copies the link to your URL. There's no
user-visible latency either -- KV is very fast.

For context, with Workers KV, we provision a Workers KV store in our
Cloudflare account (`wrangler kv:namespace create "PLAYGROUND"`), and
then create a Cloudflare Worker that's bound to the KV store via the
`wrangler.toml`:

```toml
name = "db"
main = "src/index.ts"
compatibility_date = "2023-08-07"

kv_namespaces = [
  { binding = "PLAYGROUND", id = "672e16c4fb5e4887845973bf0e9f6021", preview_id = "0a96477e116540e5a6e1eab6d6e7523e" }
]
```

The KV store exists in perpetuity, while the Worker can be updated,
deployed, removed, etc. independently of the KV store. The Worker itself
has unfettered access to the KV store. The Worker is exposed publicly,
and just does some basic verification against the request host.
This commit is contained in:
Charlie Marsh 2023-08-10 22:31:09 -04:00 committed by GitHub
parent 95dea5c868
commit 563374503f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 2693 additions and 30 deletions

View File

@ -4,11 +4,26 @@ In-browser playground for Ruff. Available [https://play.ruff.rs/](https://play.r
## Getting started
- To build the WASM module, run `npm run build:wasm`
from the `./playground` directory.
- Install TypeScript dependencies with: `npm install`.
- Start the development server with: `npm run dev`.
First, build the WASM module by running `npm run build:wasm` from the `./playground` directory.
## Implementation
Then, install TypeScript dependencies with `npm install`, and run the development server with
`npm run dev`.
Design based on [Tailwind Play](https://play.tailwindcss.com/). Themed with [`ayu`](https://github.com/dempfi/ayu).
To run the datastore, which is based on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/),
install the [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/),
then run `npx wrangler dev --local` from the `./playground/db` directory. Note that the datastore is
only required to generate shareable URLs for code snippets. The development datastore does not
require Cloudflare authentication or login, but in turn only persists data locally.
## Architecture
The playground is implemented as a single-page React application powered by
[Vite](https://vitejs.dev/), with the editor experience itself powered by
[Monaco](https://github.com/microsoft/monaco-editor).
The playground stores state in `localStorage`, but can supports persisting code snippets to
a persistent datastore based on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/)
and exposed via a [Cloudflare Worker](https://developers.cloudflare.com/workers/learning/how-workers-works/).
The playground design is originally based on [Tailwind Play](https://play.tailwindcss.com/), with
additional inspiration from the [Rome Tools Playground](https://docs.rome.tools/playground/).

2
playground/api/.dev.vars Normal file
View File

@ -0,0 +1,2 @@
# See: https://developers.cloudflare.com/workers/wrangler/configuration/#environmental-variables
DEV=1

5
playground/api/README.md Normal file
View File

@ -0,0 +1,5 @@
# api
Key-value store based on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/).
Used to persist code snippets in the playground and generate shareable URLs.

2369
playground/api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
{
"name": "api",
"version": "0.0.0",
"devDependencies": {
"@cloudflare/workers-types": "^4.20230801.0",
"miniflare": "^3.20230801.1",
"typescript": "^5.1.6",
"wrangler": "2.0.27"
},
"private": true,
"scripts": {
"start": "wrangler dev",
"deploy": "wrangler publish"
},
"dependencies": {
"@miniflare/kv": "^2.14.0",
"@miniflare/storage-memory": "^2.14.0",
"uuid": "^9.0.0"
}
}

View File

@ -0,0 +1,91 @@
/**
* A Workers KV-based database for storing shareable code snippets in the Playground.
*/
export interface Env {
// The Workers KV namespace to use for storing code snippets.
PLAYGROUND: KVNamespace;
// Whether or not we're in a development environment.
DEV?: boolean;
}
// See: https://developers.cloudflare.com/workers/examples/security-headers/
const DEFAULT_HEADERS = {
"Permissions-Policy": "interest-cohort=()",
"X-XSS-Protection": "0",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cross-Origin-Embedder-Policy": 'require-corp; report-to="default";',
"Cross-Origin-Opener-Policy": 'same-site; report-to="default";',
"Cross-Origin-Resource-Policy": "same-site",
};
const DEVELOPMENT_HEADERS = {
...DEFAULT_HEADERS,
"Access-Control-Allow-Origin": "*",
};
const PRODUCTION_HEADERS = {
...DEFAULT_HEADERS,
"Access-Control-Allow-Origin": "https://play.ruff.rs",
};
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const { DEV, PLAYGROUND } = env;
const headers = DEV ? DEVELOPMENT_HEADERS : PRODUCTION_HEADERS;
switch (request.method) {
case "GET": {
// Ex) `https://api.astral-1ad.workers.dev/<key>`
// Given an ID, return the corresponding playground.
const { pathname } = new URL(request.url);
const key = pathname.slice(1);
if (!key) {
return new Response("Not Found", {
status: 404,
headers,
});
}
const playground = await PLAYGROUND.get(key);
if (playground === null) {
return new Response("Not Found", {
status: 404,
headers,
});
}
return new Response(playground, {
status: 200,
headers,
});
}
// Ex) `https://api.astral-1ad.workers.dev`
// Given a playground, save it and return its ID.
case "POST": {
const id = crypto.randomUUID();
const playground = await request.text();
await PLAYGROUND.put(id, playground);
return new Response(id, {
status: 200,
headers,
});
}
default: {
return new Response("Method Not Allowed", {
status: 405,
headers,
});
}
}
},
};

View File

@ -0,0 +1,105 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
"resolveJsonModule": true /* Enable importing .json files */,
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
"noEmit": true /* Disable emitting files from a compilation. */,
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
// "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -0,0 +1,7 @@
name = "api"
main = "src/index.ts"
compatibility_date = "2023-08-07"
kv_namespaces = [
{ binding = "PLAYGROUND", id = "672e16c4fb5e4887845973bf0e9f6021", preview_id = "0a96477e116540e5a6e1eab6d6e7523e" }
]

View File

@ -10,7 +10,7 @@
"dependencies": {
"@monaco-editor/react": "^4.4.6",
"classnames": "^2.3.2",
"lz-string": "^1.4.4",
"lz-string": "^1.5.0",
"monaco-editor": "^0.40.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -17,7 +17,7 @@
"dependencies": {
"@monaco-editor/react": "^4.4.6",
"classnames": "^2.3.2",
"lz-string": "^1.4.4",
"lz-string": "^1.5.0",
"monaco-editor": "^0.40.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -66,7 +66,7 @@ export default function Editor() {
const initialized = ruffVersion != null;
// Ideally this would be retrieved right from the URl... but routing without a proper
// Ideally this would be retrieved right from the URL... but routing without a proper
// router is hard (there's no location changed event) and pulling in a router
// feels overkill.
const handleSecondaryToolSelected = (tool: SecondaryTool | null) => {
@ -88,8 +88,10 @@ export default function Editor() {
};
useEffect(() => {
init().then(() => {
const [settingsSource, pythonSource] = restore() ?? [
async function initAsync() {
await init();
const response = await restore();
const [settingsSource, pythonSource] = response ?? [
stringify(Workspace.defaultSettings()),
DEFAULT_PYTHON_SOURCE,
];
@ -100,7 +102,8 @@ export default function Editor() {
settingsSource,
});
setRuffVersion(Workspace.version());
});
}
initAsync().catch(console.error);
}, []);
const deferredSource = useDeferredValue(source);
@ -182,7 +185,7 @@ export default function Editor() {
}
return () => {
persist(source.settingsSource, source.pythonSource);
return persist(source.settingsSource, source.pythonSource);
};
}, [source, initialized]);
@ -202,8 +205,6 @@ export default function Editor() {
}));
}, []);
// useMonacoTheme();
return (
<main className="flex flex-col h-full bg-ayu-background dark:bg-ayu-background-dark">
<Header

View File

@ -0,0 +1,33 @@
const API_URL = import.meta.env.PROD
? "https://api.astral-1ad.workers.dev"
: "http://0.0.0.0:8787";
export type Playground = {
pythonSource: string;
settingsSource: string;
};
/**
* Fetch a playground by ID.
*/
export async function fetchPlayground(id: string): Promise<Playground | null> {
const response = await fetch(`${API_URL}/${encodeURIComponent(id)}`);
if (!response.ok) {
throw new Error(`Failed to fetch playground ${id}: ${response.status}`);
}
return await response.json();
}
/**
* Save a playground and return its ID.
*/
export async function savePlayground(playground: Playground): Promise<string> {
const response = await fetch(API_URL, {
method: "POST",
body: JSON.stringify(playground),
});
if (!response.ok) {
throw new Error(`Failed to save playground: ${response.status}`);
}
return await response.text();
}

View File

@ -1,4 +1,5 @@
import lzstring from "lz-string";
import { fetchPlayground, savePlayground } from "./api";
export type Settings = { [K: string]: any };
@ -22,29 +23,43 @@ export function stringify(settings: Settings): string {
/**
* Persist the configuration to a URL.
*/
export async function persist(settingsSource: string, pythonSource: string) {
const hash = lzstring.compressToEncodedURIComponent(
settingsSource.replaceAll("$$$", "$$$$$$") + "$$$" + pythonSource,
);
await navigator.clipboard.writeText(
window.location.href.split("#")[0] + "#" + hash,
);
export async function persist(
settingsSource: string,
pythonSource: string,
): Promise<void> {
const id = await savePlayground({ settingsSource, pythonSource });
await navigator.clipboard.writeText(`${window.location.origin}/${id}`);
}
/**
* Restore the configuration from a URL.
*/
export function restore(): [string, string] | null {
const value = lzstring.decompressFromEncodedURIComponent(
window.location.hash.slice(1),
);
if (value == null) {
return restoreLocal();
} else {
export async function restore(): Promise<[string, string] | null> {
// Legacy URLs, stored as encoded strings in the hash, like:
// https://play.ruff.rs/#eyJzZXR0aW5nc1NvdXJjZ...
const hash = window.location.hash.slice(1);
if (hash) {
const value = lzstring.decompressFromEncodedURIComponent(
window.location.hash.slice(1),
)!;
const [settingsSource, pythonSource] = value.split("$$$");
return [settingsSource.replaceAll("$$$$$$", "$$$"), pythonSource];
}
// URLs stored in the database, like:
// https://play.ruff.rs/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed
const id = window.location.pathname.slice(1);
if (id) {
const playground = await fetchPlayground(id);
if (playground == null) {
return null;
}
const { settingsSource, pythonSource } = playground;
return [settingsSource, pythonSource];
}
// If no URL is present, restore from local storage.
return restoreLocal();
}
function restoreLocal(): [string, string] | null {