// (c) easrng 2024 all rights reserved /** @jsx jsx */ /** @jsxFrag Fragment */ import { createContext, type FC, Fragment, jsx, type PropsWithChildren, useContext, } from "https://deno.land/x/hono@v4.0.10/jsx/index.ts"; import { Hono } from "https://deno.land/x/hono@v4.0.10/mod.ts"; import { API_URL } from "https://esm.town/v/std/API_URL?v=5"; import { create } from "https://esm.town/v/websandbox/create"; import { parse as parseStack } from "npm:error-stack-parser-es@0.1.1"; import * as stylis from "npm:stylis@4.3.1"; import * as SuperJSON from "npm:superjson@2.2.1"; globalThis.addEventListener("unhandledrejection", (event) => { event.preventDefault(); console.error("Uncaught (in promise) %o", event.reason) }); const app = new Hono(); const Layout: FC<PropsWithChildren<{ title: string }>> = (props) => { return ( <html> <head> <title>{props.title}</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> {String.raw` * { box-sizing: border-box; } body, html { width: 100%; height: 100%; margin: 0; padding: 0; color-scheme: light dark; } pre { font-size: 1rem; } `} </style> </head> <body>{props.children}</body> </html> ); }; const StacktraceLink: FC<{ href: string; line?: number; col?: number }> = ({ href, line, col, }) => { let link: string | JSX.Element = href; try { const url = new URL(href); if (url.protocol === "https:" || url.protocol === "http:") { link = <a href={url.href}>{url.href}</a>; } } catch {} return ( <> {link} {line ? `:${line}${col ? ":" + col : ""}` : ""} </> ); }; const defaultFormat = (args: unknown[]) => { const first = args[0]; let a = 0; let out: string[] = []; let styled: JSX.Element[] = []; let css: string = ""; function flush() { if (out.length) { styled.push(css ? <span style={css}>{out}</span> : <>{out}</>); out = []; } } if (typeof first == "string" && args.length > 1) { a++; // Index of the first not-yet-appended character. Use this so we only // have to append to `string` when a substitution occurs / at the end. let appendedChars = 0; for (let i = 0; i < first.length - 1; i++) { if (first[i] == "%") { const char = first[++i]; if (a < args.length) { let formattedArg = ""; if (char == "s") { // Format as a string. formattedArg = String(args[a++]); } else if (Array.prototype.includes.call(["d", "i"], char)) { // Format as an integer. const value = args[a++]; if (typeof value == "bigint") { formattedArg = `${value}n`; } else if (typeof value == "number") { formattedArg = `${Number.parseInt(String(value))}`; } else { formattedArg = "NaN"; } } else if (char == "f") { // Format as a floating point value. const value = args[a++]; if (typeof value == "number") { formattedArg = `${value}`; } else { formattedArg = "NaN"; } } else if (Array.prototype.includes.call(["O", "o"], char)) { // Format as an object. formattedArg = Deno.inspect(args[a++]); } else if (char == "c") { const value = String(args[a++]); const { compile, serialize, stringify } = stylis; flush(); css = serialize( compile(value).filter(style => style.type === "decl" && /^color$|^font-(weight|style)$|^text-decoration|^background-color$/.test(String(style.props)) ), stringify, ); } if (formattedArg != null) { out.push( String.prototype.slice.call(first, appendedChars, i - 1) + formattedArg, ); appendedChars = i + 1; } } if (char == "%") { out.push(String.prototype.slice.call(first, appendedChars, i - 1) + "%"); appendedChars = i + 1; } } } out.push(String.prototype.slice.call(first, appendedChars)); } flush(); css = ""; for (; a < args.length; a++) { if (a > 0) { out.push(" "); } if (typeof args[a] == "string") { out.push(String(args[a])); } else { out.push(Deno.inspect(args[a])); } } flush(); return ( <span class="message"> {styled} </span> ); }; function transpose(matrix) { return matrix[0].map((col, i) => matrix.map((row) => row[i])); } const table = (data = undefined, properties = undefined) => { if (properties !== undefined && !Array.isArray(properties)) { /*throw new Error( "The 'properties' argument must be of type Array. " + "Received type " + typeof properties, );*/ return defaultFormat([data, properties]); } if (data === null || typeof data !== "object") { return defaultFormat([data, properties]); } let resultData; const isSetObject = data instanceof Set; const isMapObject = data instanceof Map; const valuesKey = "Values"; const indexKey = isSetObject || isMapObject ? "(iter idx)" : "(idx)"; if (isSetObject) { resultData = [...data]; } else if (isMapObject) { let idx = 0; resultData = {}; Map.prototype.forEach.call(data, (v, k) => { resultData[idx] = { Key: k, Values: v }; idx++; }); } else { resultData = data; } const keys = Object.keys(resultData); const numRows = keys.length; const objectValues = properties ? Object.fromEntries( Array.prototype.map.call(properties, (name) => [ name, Array.prototype.fill.call(new Array(numRows), ""), ]), ) : {}; const indexKeys = []; const values = []; let hasPrimitives = false; Array.prototype.forEach.call(keys, (k, idx) => { const value = resultData[k]; const primitive = value === null || (typeof value !== "function" && typeof value !== "object"); if (properties === undefined && primitive) { hasPrimitives = true; Array.prototype.push.call(values, defaultFormat([value])); } else { const valueObj = value || {}; const keys = properties || Object.keys(valueObj); for (let i = 0; i < keys.length; ++i) { const k = keys[i]; if (!primitive && Reflect.has(valueObj, k)) { if (!Reflect.has(objectValues, k)) { objectValues[k] = Array.prototype.fill.call(new Array(numRows), ""); } objectValues[k][idx] = defaultFormat([valueObj[k]]); } } Array.prototype.push.call(values, ""); } Array.prototype.push.call(indexKeys, k); }); const headerKeys = Object.keys(objectValues); const bodyValues = Object.values(objectValues); const headerProps = properties || [ ...headerKeys, !isMapObject && hasPrimitives && valuesKey, ]; const header = Array.prototype.filter.call( [indexKey, ...headerProps], Boolean, ); const body = [indexKeys, ...bodyValues, values]; return ( <div class="message"> <div class="table-wrap"> <table> <thead> <tr> {header.map((e) => <th>{e}</th>)} </tr> </thead> <tbody> {transpose(body).map((e) => ( <tr> {e .slice(0, header.length) .map((e, i) => (i ? <td>{e}</td> : <th>{e}</th>))} </tr> ))} </tbody> </table> </div> </div> ); }; const icons = { error: "⛔", warn: "⚠️", info: "ℹ️", debug: "🐞", unknown: "❓", }; const known = new Set([ "log", "clear", "table", "assert", "count", "countReset", "dir", "dirxml", "exception", "time", "timeEnd", "timeLog", "timeStamp", "trace", "profile", "profileEnd", ...Object.keys(icons), ]); const CountContext = createContext<Map<string, number>>(new Map()); const TimeContext = createContext<Map<string, number>>(new Map()); const LogLine: FC<{ log: Log }> = ({ log }) => { const counts = useContext(CountContext); const times = useContext(TimeContext); let stackEle = <></>; let unknownType: string | void; if (!known.has(log.type)) { unknownType = log.type; log.type = "unknown"; } if (log.type === "timeStamp" || log.type === "profile" || log.type === "profileEnd") { return <></>; } if (log.type === "assert") { if (log.args[0]) return <></>; log.type = "error"; log.args[0] = "Assertion failed:"; log.args.push("\n" + log.stack); } const label = log.args[0] === undefined ? "default" : `${log.args[0]}`; { const reset = log.type === "countReset"; if (log.type === "count" || log.type === "countReset") { const newCount = reset ? 0 : (counts.get(label) || 0) + 1; counts.set(label, newCount); if (reset) return <></>; log.args = [label + ":", newCount]; log.type = "count"; } } if (log.type === "time" || log.type === "countReset") { if (times.has(label)) { log.type = "warn"; log.args = [`Timer '${label}' already exists.`]; } else { times.set(label, log.time); return <></>; } } if (log.type === "timeLog" || log.type === "timeEnd") { if (times.has(label)) { log.args = [`${label}: ${log.time - times.get(label)}ms`]; if (log.type === "timeEnd") { times.delete(label); } } else { log.type = "warn"; log.args = [`Timer '${label}' doesn't exist.`]; } } if (log.type === "dir" || log.type === "dirxml") { log.type = "log"; } if (log.type === "exception") { log.type = "error"; } if (log.type === "trace") { log.args.unshift("Trace" + (log.args.length ? ":" : "")); log.args.push("\n" + log.stack); } try { const stack = parseStack(log as unknown as Error); stackEle = ( <details class="stack"> <summary>{stack[0].fileName}</summary> <ul> {stack.map((item) => { const file = ( <StacktraceLink href={item.fileName} line={item.lineNumber} col={item.columnNumber} /> ); return ( <li> at {item.functionName ? ( <> {item.functionName} ({file}) </> ) : file} </li> ); })} </ul> </details> ); } catch {} const icon = icons[log.type] ? ( <span class="icon" title={log.type} aria-label={log.type}> {icons[log.type]} </span> ) : ( "" ); let content; if (log.type === "clear") { content = <i class="message">Ignored console.clear()</i>; } else if (log.type === "table") { content = table(...log.args); } else if (log.type === "unknown") { content = ( <> <b>{unknownType}</b> {":\xa0"} {defaultFormat(log.args)} </> ); } else { content = defaultFormat(log.args); } return ( <li class={"log log-" + log.type}> {icon} {content} {stackEle} </li> ); }; const Output: FC<{ logs: Log[] }> = ({ logs }) => { return ( <Layout title="Output"> <base target="_blank" /> <style> {String.raw` .message { white-space: pre-wrap; flex-shrink: 1; flex-grow: 1; overflow: hidden; } body { font-family: monospace; font-size: 1rem; background: Canvas; overflow-wrap: break-word; } .stack { display: contents; } .stack summary { display: inline-block; text-decoration: underline; cursor: pointer; flex-shrink: 0; text-align: right; max-width: fit-content; width: calc(80% - 5rem); white-space: nowrap; overflow: hidden; overflow-wrap: none; text-overflow: ellipsis; } .stack ul { flex-basis: 100%; } .stack li { list-style: none; } .log { display: flex; flex-wrap: wrap; padding: 0.5rem; border-bottom: 1px solid GrayText; padding-left: 2rem; position: relative; overflow: hidden; width: 100%; } .icon { position: absolute; left: 0.5rem; font-size: 0.8rem; line-height: 1.25; } .log-error { background: color-mix(in srgb, Canvas, #f00 15%); } .log-warn { background: color-mix(in srgb, Canvas, #fc0 15%); } .logs { display: block; margin: 0; padding: 0; } .log-table .table-wrap { border: 1px solid; box-shadow: inset 0 0 0 1px; overflow-x: auto; max-width: fit-content; } table { border-collapse: collapse; } th, td { border: 1px solid; padding: 0.25rem; text-align: left; } th { font-weight: bold; background: color-mix(in srgb, Canvas, CanvasText 12%) } a, .stack summary { color: color-mix(in srgb, Canvas, CanvasText 75%); } `} </style> <ul class="logs"> <CountContext.Provider value={new Map()}> <TimeContext.Provider value={new Map()}> {logs.map((log) => <LogLine log={log} />)} </TimeContext.Provider> </CountContext.Provider> </ul> </Layout> ); }; const App: FC<{ code: string; http: boolean }> = ({ code, http }) => { const httpToken = http && crypto.randomUUID(); const frame = ( <iframe name="output" id="output" class="out" srcdoc={( <html style={"color-scheme:dark light;text-align:right;background:Canvas;height:100%"}> <body style={http ? "margin:0;height:100%;display:flex;align-items:center;justify-content:end;" : ""}> <code style="font-size:1rem">hit run {http ? "→" : "⤴"}{"\xa0"}</code> </body> </html> ).toString()} > </iframe> ); return ( <Layout title="Playground"> <style> {String.raw` body { display: flex; flex-direction: column; font-family: system-ui, sans-serif; } textarea, .cm-editor { display: block; border: 1px solid GrayText; border-bottom-width: 0; margin: 0; resize: none; font-size: 1rem; font-family: monospace; flex-grow: 1; } textarea { padding: 0.5rem; background: Canvas; color: CanvasText; } textarea:focus, .cm-editor:focus-within { outline: 0; border-color: SelectedItem; border-bottom-width: 1px; } .toolbar { --toolbar-bg: color-mix(in srgb, Canvas, CanvasText 10%); background: var(--toolbar-bg); border: 1px solid GrayText; display: flex; padding: 0.25rem; padding-bottom: 0; align-items: start; position: relative; --frame-height: calc(20vh + 8rem); gap: 0.25rem; } details { display: flex; flex-direction: column; flex-grow: 1; } .toolbar button, .toolbar summary { height: 1.75rem; font-size: 0.95rem; display: inline-flex; align-items: center; justify-content: center; width: fit-content; border: 1px solid transparent; padding: 0 0.5rem; margin-bottom: 0.25rem; } .toolbar button { background: color-mix(in srgb, Canvas, CanvasText 3%); color: CanvasText; border-color: GrayText; border-radius: 4px; box-shadow: 0 1px GrayText; } .toolbar button:hover { background: Canvas; } .toolbar button:active { transform: translateY(1px); box-shadow: 0 0 GrayText; } summary { box-shadow: 0 calc(0.25rem - 2px) var(--toolbar-bg), 0 0.25rem GrayText; } [open] summary { box-shadow: 0 calc(0.25rem - 2px) var(--toolbar-bg), 0 0.25rem SelectedItem; } .toolbar:not(.http) .out { position: absolute; border: none; bottom: 0; left: 0; right: 0; width: 100%; height: var(--frame-height); border-top: 1px solid GrayText; background: Canvas; } .toolbar.http .out { flex-grow: 1; flex-shrink: 1; width: 0; border: 1px solid GrayText; border-radius: 2px; background: Canvas; margin-bottom: 0.25rem; height: 1.75rem; } .spacer { height: var(--frame-height); } form { display: contents; } `} </style> <form target="output" action="/run" method="post" id="form"> {http && <input type="hidden" name="httpToken" value={httpToken} />} <textarea autocomplete="off" name="code"> {code} </textarea> <div class={"toolbar" + (http ? " http" : "")}> {http ? frame : ( <details id="details" open> <summary class="logs">Output</summary> {frame} <span class="spacer"></span> </details> )} <button formaction="/save" formtarget="_blank"> Save </button> <button>Run</button> </div> </form> <script dangerouslySetInnerHTML={{ __html: String.raw` const output = document.getElementById('output');${ http ? "" : ` const details = document.getElementById('details'); details.open = false;` } const form = document.getElementById('form'); form.addEventListener("submit", (e) => { if(e.submitter.getAttribute("formaction")) return; e.preventDefault() output.srcdoc=${ JSON.stringify( ( <html style="color-scheme:dark light;background:Canvas;height:100%"> <body style={http ? "margin:0;height:100%;display:flex;align-items:center;padding:0 0.25rem" : ""}> <code style="font-size:1rem"> <span id="load" aria-hidden="true"> {"/"} </span> {"\xa0"}Running... </code> <script dangerouslySetInnerHTML={{ __html: String .raw`const dots = '-\\|/'.split('');setInterval(() => dots.push(load.textContent=dots.shift()), 80)`, }} /> </body> </html> ).toString(), ).replace(/<\//g, "<\\/") } output.addEventListener("load", () => {${http ? "" : "details.open = true;"}form.submit()}, {once: true}) }) `, }} /> </Layout> ); }; app.get("/", async (c) => { let { code, load, type } = c.req.query(); const http = type === "http"; if (!code) { if (load) { try { code = await (await fetch(new URL(load, "https://esm.town/v/"))).text(); } catch (e) { code = `/*\nFailed to load '${load}': ${e}\n*/`; } } else { code = http ? "export default () => Response.json({hello: \"world\"})" : "console.log(\"Hello, World!\")"; } } return c.html(<App code={code} http={http} />); }); app.post("/save", async (c) => { const body = await c.req.parseBody(); return c.redirect( "https://www.val.town/new?code=" + encodeURIComponent(String(body.code)), ); }); app.post("/run", async (c) => { try { const body = await c.req.parseBody(); if (typeof body.code !== "string") return c.notFound(); if (typeof body.httpToken === "string") { const url = await create({ code: body.code, token: body.httpToken, }); return c.html( <html style="color-scheme:dark light;background:Canvas;height:100%"> <body style="margin:0;height:100%;display:flex;align-items:center;padding:0 0.25rem;white-space:nowrap;overflow-x:auto"> <code style="font-size:1rem"> {url} </code> </body> </html>, ); } else { const res = await fetch(API_URL + "/v1/eval", { method: "POST", body: JSON.stringify({ code: `async (code) => await(await import(${JSON.stringify(import.meta.url)})).serializedExecute(code)`, args: [body.code], }), }); if (!res.ok) { return c.html( <Output logs={[ { type: "error", args: [await res.text()], time: Date.now(), stack: new Error().stack.replace(/^.+\n/, ""), }, ]} />, ); } const text = await res.text(); let logs: Log[]; try { ({ logs } = await (SuperJSON.parse( text, ) as ReturnType<typeof execute>)); } catch (e) { return c.html( <Output logs={[ { type: "error", args: ["Runner didn't return JSON: %o", text], time: Date.now(), stack: new Error().stack.replace(/^.+\n/, ""), }, ]} />, ); } return c.html(<Output logs={logs} />); } } catch (e) { return c.text(e.stack); } }); export default app.fetch; type Log = { type: string; args: unknown[]; time: number; stack: string; }; async function execute(code: string): Promise<{ logs: Log[] }> { try { const blob = new Blob([code], { type: "text/tsx", }); const url = URL.createObjectURL(blob); const markStackStart = crypto.randomUUID(); const markStackEnd = crypto.randomUUID(); function cleanStack(stack: string) { let lines: string[] = []; for (const line of stack.split("\n")) { if (line.includes(markStackEnd)) break; lines.push(line.replace(url, "input.tsx")); if (line.includes(markStackStart)) { lines = []; } } return lines.join("\n"); } const logs: Log[] = []; globalThis.console = new Proxy(console, { get(target, key) { const real = target[key]; if (typeof real === "function" && typeof key === "string") { const fn = function(...args: any[]) { logs.push({ type: key, args, time: Date.now(), stack: cleanStack(new Error().stack), }); return real.call(this, ...args); }; Object.defineProperty(fn, "name", { writable: true, value: markStackStart, }); return fn; } }, }); async function run() { try { await import(url); } catch (e) { logs.push({ type: "error", args: ["Uncaught %o", e], time: Date.now(), stack: e instanceof Error ? cleanStack(e.stack) : undefined, }); } } Object.defineProperty(run, "name", { writable: true, value: markStackEnd, }); await run(); URL.revokeObjectURL(url); return { logs, }; } catch (error) { return { logs: [ { type: "error", args: ["Uncaught (in runtime) %o", error], time: Date.now(), stack: undefined, }, ], }; } } export const serializedExecute = async (code: string) => SuperJSON.stringify(await execute(code));
Output
Save
Run