From 7070799862a10664018e4c0054b646fd99ba7a37 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 21 Aug 2026 16:42:02 +0000 Subject: [PATCH] Add Go CDN and playground server Build Core and Handles into in-memory CDN modules, add the marketing site and tutorial docs, and provide a SQLite-backed WebGPU playground with TypeScript tooling and profiling. Amp-Thread-ID: https://ampcode.com/threads/T-01a02485-5574-707c-bff4-5668d83bee8a Co-authored-by: Heaust Azure --- .agents/resume | 2 +- .agents/setup | 4 + .amp/services.yaml | 6 +- .gitignore | 1 + README.md | 12 +- core/lib.rs | 3 + go.work | 3 + package-lock.json | 493 ++++++++++++++++++++++++ package.json | 8 +- server/app.go | 211 +++++++++++ server/app_test.go | 137 +++++++ server/build.mjs | 84 +++++ server/database.go | 153 ++++++++ server/go.mod | 5 + server/go.sum | 2 + server/main.go | 108 ++++++ server/packages.go | 105 ++++++ server/web/assets/docs.css | 95 +++++ server/web/assets/docs.js | 44 +++ server/web/assets/playground.css | 163 ++++++++ server/web/assets/playground.js | 625 +++++++++++++++++++++++++++++++ server/web/assets/site.css | 254 +++++++++++++ server/web/assets/site.js | 18 + server/web/assets/yawn.d.ts | 161 ++++++++ server/web/docs.html | 369 ++++++++++++++++++ server/web/home.html | 205 ++++++++++ server/web/playground.html | 138 +++++++ 27 files changed, 3402 insertions(+), 7 deletions(-) create mode 100644 go.work create mode 100644 server/app.go create mode 100644 server/app_test.go create mode 100644 server/build.mjs create mode 100644 server/database.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go create mode 100644 server/packages.go create mode 100644 server/web/assets/docs.css create mode 100644 server/web/assets/docs.js create mode 100644 server/web/assets/playground.css create mode 100644 server/web/assets/playground.js create mode 100644 server/web/assets/site.css create mode 100644 server/web/assets/site.js create mode 100644 server/web/assets/yawn.d.ts create mode 100644 server/web/docs.html create mode 100644 server/web/home.html create mode 100644 server/web/playground.html diff --git a/.agents/resume b/.agents/resume index 3b1d1a1..12af56f 100755 --- a/.agents/resume +++ b/.agents/resume @@ -2,5 +2,5 @@ set -euo pipefail export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" -for command in npm cargo wasm-pack; do command -v "$command" >/dev/null; done +for command in npm cargo wasm-pack go; do command -v "$command" >/dev/null; done amp orb services ensure diff --git a/.agents/setup b/.agents/setup index cde975b..409be7d 100755 --- a/.agents/setup +++ b/.agents/setup @@ -10,6 +10,10 @@ rustup toolchain install "$toolchain" --profile minimal --component rust-src,rus if ! command -v wasm-pack >/dev/null; then cargo install wasm-pack --version 0.15.0 --locked fi +if ! command -v go >/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y golang-go +fi if ! command -v git-lfs >/dev/null; then sudo apt-get update -qq sudo apt-get install -y git-lfs diff --git a/.amp/services.yaml b/.amp/services.yaml index 584c2b8..7aba382 100644 --- a/.amp/services.yaml +++ b/.amp/services.yaml @@ -1,6 +1,6 @@ services: - yawn-docs: - command: npm start + yawn-server: + command: npm run server portal: title: Yawn - description: Documentation and minimal WebGPU playground. + description: Yawn website, tutorial, package server, and WebGPU playground. diff --git a/.gitignore b/.gitignore index 51bb85b..b1c3de8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ core/pkg docs/.vitepress/cache/ docs/.vitepress/dist/ +server/data/ # Amp runtime artifacts .amp/in/ diff --git a/README.md b/README.md index 73e9f2e..2edf3f1 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,18 @@ The arena starts with only one eight-float `signals` row for frame timing and re WGSL, pipelines, glTF import, and conventional mesh/camera/material handles live in `addons/`; core contains no shader or scene model. +Run the Go website, in-memory package server, SQLite-backed playground, and tutorial: + ```sh -npm start +npm run server ``` -This opens the docs. The complete runnable example is at `/playground`. +The server rebuilds `@yawn/core` and `@yawn/handles` at startup and serves the public CDN modules at +`https://yawn.heaust.org/pkg/core.js` and `https://yawn.heaust.org/pkg/handles.js`. Their workers and +WASM stay on the CDN automatically, so applications only import the module URLs. The complete editor is at `/playground`; saved revisions use +`/playground/{id}/{revision}` URLs. Set `PORT` or `YAWN_DATABASE` to override the default port and +`server/data/yawn.db` database path. + +Run the previous VitePress documentation site with `npm start`. Run `npm run coredocs` for the raw worker-message, shared-memory, and render-graph reference intended for custom handles, editors, and direct SAB clients. diff --git a/core/lib.rs b/core/lib.rs index c2b7548..913e1f9 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -219,6 +219,9 @@ impl Core { .as_ref() .is_some_and(|gpu| gpu.timestamp_queries); self.render.set_profiling(enabled && supported); + if enabled && supported { + self.data.borrow_mut().mark_dirty(); + } supported } diff --git a/go.work b/go.work new file mode 100644 index 0000000..b62cae1 --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.19 + +use ./server diff --git a/package-lock.json b/package-lock.json index 26ee657..8e87ef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@codemirror/lang-javascript": "^6.2.5", "@codemirror/theme-one-dark": "^6.1.3", "codemirror": "^6.0.2", + "esbuild": "^0.25.9", + "monaco-editor": "^0.52.2", "vitepress": "^1.6.4" } }, @@ -777,6 +779,40 @@ "node": ">=12" } }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -794,6 +830,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -811,6 +864,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -1562,6 +1632,422 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -1810,6 +2296,13 @@ "dev": true, "license": "MIT" }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", diff --git a/package.json b/package.json index 015bda7..3347c17 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,13 @@ "version": "0.1.0", "private": true, "type": "module", - "workspaces": ["core", "addons/*"], + "workspaces": [ + "core", + "addons/*" + ], "scripts": { "start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}", + "server": "go run ./server", "coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}", "build:coredocs": "vitepress build coredocs" }, @@ -13,6 +17,8 @@ "@codemirror/lang-javascript": "^6.2.5", "@codemirror/theme-one-dark": "^6.1.3", "codemirror": "^6.0.2", + "esbuild": "^0.25.9", + "monaco-editor": "^0.52.2", "vitepress": "^1.6.4" } } diff --git a/server/app.go b/server/app.go new file mode 100644 index 0000000..a5eb1cb --- /dev/null +++ b/server/app.go @@ -0,0 +1,211 @@ +package main + +import ( + "bytes" + "database/sql" + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path" + "strconv" + "strings" +) + +//go:embed web +var webFiles embed.FS + +type application struct { + store *playgroundStore + packages map[string]memoryAsset + monaco http.Handler + assets http.Handler + pages map[string][]byte +} + +func newApplication(store *playgroundStore, packages map[string]memoryAsset, monacoDirectory string) (*application, error) { + if info, err := os.Stat(monacoDirectory); err != nil || !info.IsDir() { + return nil, fmt.Errorf("Monaco assets not found at %s; run npm install", monacoDirectory) + } + assets, err := fs.Sub(webFiles, "web/assets") + if err != nil { + return nil, err + } + pages := make(map[string][]byte) + for _, name := range []string{"home.html", "docs.html", "playground.html"} { + content, err := webFiles.ReadFile("web/" + name) + if err != nil { + return nil, err + } + pages[name] = content + } + return &application{ + store: store, + packages: packages, + monaco: http.StripPrefix("/assets/monaco/", http.FileServer(http.Dir(monacoDirectory))), + assets: http.StripPrefix("/assets/", http.FileServer(http.FS(assets))), + pages: pages, + }, nil +} + +func (app *application) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/pkg/", app.servePackage) + mux.HandleFunc("/api/playgrounds", app.savePlayground) + mux.HandleFunc("/api/playgrounds/", app.getPlayground) + mux.Handle("/assets/monaco/", app.monaco) + mux.Handle("/assets/", app.assets) + mux.HandleFunc("/docs", app.servePage("docs.html")) + mux.HandleFunc("/docs/", app.servePage("docs.html")) + mux.HandleFunc("/playground", app.servePlayground) + mux.HandleFunc("/playground/", app.servePlayground) + mux.HandleFunc("/", app.serveHome) + return app.headers(mux) +} + +func (app *application) headers(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + response.Header().Set("Cross-Origin-Embedder-Policy", "require-corp") + response.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + response.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("X-Frame-Options", "SAMEORIGIN") + next.ServeHTTP(response, request) + }) +} + +func (app *application) serveHome(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/" { + http.NotFound(response, request) + return + } + app.writePage(response, request, "home.html") +} + +func (app *application) servePage(name string) http.HandlerFunc { + return func(response http.ResponseWriter, request *http.Request) { + app.writePage(response, request, name) + } +} + +func (app *application) servePlayground(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/playground" && request.URL.Path != "/playground/" { + parts := strings.Split(strings.Trim(request.URL.Path, "/"), "/") + if len(parts) != 3 || parts[0] != "playground" || !playgroundIDPattern.MatchString(parts[1]) { + http.NotFound(response, request) + return + } + if revision, err := strconv.Atoi(parts[2]); err != nil || revision < 1 { + http.NotFound(response, request) + return + } + } + app.writePage(response, request, "playground.html") +} + +func (app *application) writePage(response http.ResponseWriter, request *http.Request, name string) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.Header().Set("Cache-Control", "no-cache") + if request.Method == http.MethodHead { + return + } + if request.Method != http.MethodGet { + response.Header().Set("Allow", "GET, HEAD") + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + _, _ = response.Write(app.pages[name]) +} + +func (app *application) servePackage(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet && request.Method != http.MethodHead { + response.Header().Set("Allow", "GET, HEAD") + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + name := strings.TrimPrefix(path.Clean(request.URL.Path), "/pkg/") + asset, exists := app.packages[name] + if !exists || name == "." { + http.NotFound(response, request) + return + } + response.Header().Set("Access-Control-Allow-Origin", "*") + response.Header().Set("Cache-Control", "no-cache") + response.Header().Set("Content-Type", asset.contentType) + response.Header().Set("Cross-Origin-Resource-Policy", "cross-origin") + response.Header().Set("ETag", asset.etag) + if request.Header.Get("If-None-Match") == asset.etag { + response.WriteHeader(http.StatusNotModified) + return + } + http.ServeContent(response, request, name, asset.modified, bytes.NewReader(asset.content)) +} + +func (app *application) savePlayground(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + response.Header().Set("Allow", "POST") + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + request.Body = http.MaxBytesReader(response, request.Body, maximumCodeBytes+4096) + var input struct { + ID string `json:"id"` + Title string `json:"title"` + Code string `json:"code"` + } + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + writeJSONError(response, "The save request is not valid.", http.StatusBadRequest) + return + } + result, err := app.store.save(request.Context(), input.ID, strings.TrimSpace(input.Title), input.Code) + if err != nil { + writeJSONError(response, err.Error(), http.StatusBadRequest) + return + } + writeJSON(response, result, http.StatusCreated) +} + +func (app *application) getPlayground(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + response.Header().Set("Allow", "GET") + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/api/playgrounds/"), "/") + if len(parts) != 2 { + http.NotFound(response, request) + return + } + revision, err := strconv.Atoi(parts[1]) + if err != nil { + http.NotFound(response, request) + return + } + result, err := app.store.get(request.Context(), parts[0], revision) + if errors.Is(err, sql.ErrNoRows) { + writeJSONError(response, "Playground not found.", http.StatusNotFound) + return + } + if err != nil { + writeJSONError(response, "Could not load the playground.", http.StatusInternalServerError) + return + } + response.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + writeJSON(response, result, http.StatusOK) +} + +func writeJSON(response http.ResponseWriter, value any, status int) { + response.Header().Set("Content-Type", "application/json; charset=utf-8") + response.WriteHeader(status) + _ = json.NewEncoder(response).Encode(value) +} + +func writeJSONError(response http.ResponseWriter, message string, status int) { + writeJSON(response, map[string]string{"error": message}, status) +} diff --git a/server/app_test.go b/server/app_test.go new file mode 100644 index 0000000..d87c049 --- /dev/null +++ b/server/app_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func testApplication(t *testing.T) *application { + t.Helper() + store, err := openPlaygroundStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + monaco := filepath.Join(t.TempDir(), "monaco") + if err := os.MkdirAll(monaco, 0o755); err != nil { + t.Fatal(err) + } + app, err := newApplication(store, map[string]memoryAsset{ + "core.js": { + contentType: "text/javascript; charset=utf-8", + content: []byte("export const ready = true"), + etag: `"core"`, + modified: time.Unix(0, 0), + }, + }, monaco) + if err != nil { + t.Fatal(err) + } + return app +} + +func TestPackageIsServedFromMemory(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/pkg/core.js", nil) + response := httptest.NewRecorder() + testApplication(t).routes().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + if response.Body.String() != "export const ready = true" { + t.Fatalf("body = %q", response.Body.String()) + } + if response.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" { + t.Fatal("cross-origin isolation header is missing") + } + if response.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Fatal("package CORS header is missing") + } + if response.Header().Get("Cross-Origin-Resource-Policy") != "cross-origin" { + t.Fatal("package resource policy is missing") + } +} + +func TestSaveAndLoadPlaygroundRevision(t *testing.T) { + app := testApplication(t) + handler := app.routes() + requestBody, _ := json.Marshal(map[string]string{ + "title": "First scene", + "code": "const scene = true;", + }) + request := httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("save status = %d: %s", response.Code, response.Body.String()) + } + var saved playground + if err := json.NewDecoder(response.Body).Decode(&saved); err != nil { + t.Fatal(err) + } + if saved.Revision != 1 || !playgroundIDPattern.MatchString(saved.ID) { + t.Fatalf("saved = %#v", saved) + } + requestBody, _ = json.Marshal(map[string]string{ + "id": saved.ID, + "title": "Second scene", + "code": "const scene = false;", + }) + request = httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody)) + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("second save status = %d: %s", response.Code, response.Body.String()) + } + var second playground + if err := json.NewDecoder(response.Body).Decode(&second); err != nil { + t.Fatal(err) + } + if second.ID != saved.ID || second.Revision != 2 { + t.Fatalf("second = %#v", second) + } + + request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/1", nil) + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("load status = %d: %s", response.Code, response.Body.String()) + } + var loaded playground + if err := json.NewDecoder(response.Body).Decode(&loaded); err != nil { + t.Fatal(err) + } + if loaded.Code != "const scene = true;" || loaded.Title != "First scene" { + t.Fatalf("loaded = %#v", loaded) + } + + request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/2", nil) + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("second load status = %d: %s", response.Code, response.Body.String()) + } + if err := json.NewDecoder(response.Body).Decode(&loaded); err != nil { + t.Fatal(err) + } + if loaded.Code != "const scene = false;" || loaded.Title != "Second scene" { + t.Fatalf("second loaded = %#v", loaded) + } +} + +func TestSavedPlaygroundPageRoute(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/playground/abcdef12/1", nil) + response := httptest.NewRecorder() + testApplication(t).routes().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + if contentType := response.Header().Get("Content-Type"); contentType != "text/html; charset=utf-8" { + t.Fatalf("content type = %q", contentType) + } +} diff --git a/server/build.mjs b/server/build.mjs new file mode 100644 index 0000000..f0ba9d4 --- /dev/null +++ b/server/build.mjs @@ -0,0 +1,84 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { build } from "esbuild"; + +const [root, output] = process.argv.slice(2); +if (!root || !output) throw new Error("usage: node build.mjs "); + +const browser = { + bundle: true, + format: "esm", + legalComments: "none", + minify: true, + platform: "browser", + target: "es2022", +}; + +const workerFiles = new Map([ + [path.join(root, "core", "index.js"), "./worker.js"], + [path.join(root, "addons", "handles", "src", "importers", "gltf.ts"), "./importer-worker.js"], + [path.join(root, "addons", "handles", "src", "bvh", "picking.ts"), "./bvh-worker.js"], +]); + +const workerURLs = { + name: "yawn-worker-urls", + setup(builder) { + builder.onLoad({ filter: /\.(js|ts)$/ }, async ({ path: source }) => { + const worker = workerFiles.get(path.normalize(source)); + if (!worker) return; + let contents = await fs.readFile(source, "utf8"); + const transformed = contents.replace( + /new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/, + `createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`, + ); + if (transformed === contents) throw new Error(`worker constructor not found in ${source}`); + contents = ` +function createYawnWorker(url, options) { + if (url.origin === globalThis.location.origin) return new Worker(url, options); + const source = "import " + JSON.stringify(url.href) + ";"; + const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); + const worker = new Worker(bootstrap, options); + URL.revokeObjectURL(bootstrap); + return worker; +} +${transformed}`; + return { contents, loader: path.extname(source) === ".ts" ? "ts" : "js" }; + }); + }, +}; + +await Promise.all([ + build({ + ...browser, + entryPoints: [path.join(root, "core", "index.js")], + outfile: path.join(output, "core.js"), + plugins: [workerURLs], + }), + build({ + ...browser, + alias: { "@yawn/core": path.join(root, "core", "index.js") }, + entryPoints: [path.join(root, "addons", "handles", "src", "index.ts")], + outfile: path.join(output, "handles.js"), + plugins: [workerURLs], + }), + build({ + bundle: false, + entryPoints: [path.join(root, "core", "worker.js")], + format: "esm", + legalComments: "none", + minify: true, + outfile: path.join(output, "worker.js"), + target: "es2022", + }), + build({ + ...browser, + entryPoints: [path.join(root, "addons", "handles", "src", "importers", "worker.ts")], + outfile: path.join(output, "importer-worker.js"), + }), + build({ + ...browser, + entryPoints: [path.join(root, "addons", "handles", "src", "bvh", "worker.ts")], + outfile: path.join(output, "bvh-worker.js"), + }), +]); diff --git a/server/database.go b/server/database.go new file mode 100644 index 0000000..7674b17 --- /dev/null +++ b/server/database.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "crypto/rand" + "database/sql" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "regexp" + "time" + "unicode/utf8" + + _ "github.com/mattn/go-sqlite3" +) + +const ( + maximumCodeBytes = 200 * 1024 + maximumTitleRunes = 80 +) + +var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`) + +type playground struct { + ID string `json:"id"` + Revision int `json:"revision"` + Title string `json:"title"` + Code string `json:"code"` + CreatedAt time.Time `json:"createdAt"` +} + +type playgroundStore struct { + *sql.DB +} + +func openPlaygroundStore(path string) (*playgroundStore, error) { + if path != ":memory:" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + } + database, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL") + if err != nil { + return nil, err + } + database.SetMaxOpenConns(1) + store := &playgroundStore{DB: database} + if err := store.initialize(); err != nil { + database.Close() + return nil, err + } + return store, nil +} + +func (store *playgroundStore) initialize() error { + _, err := store.Exec(` + CREATE TABLE IF NOT EXISTS playgrounds ( + id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + title TEXT NOT NULL, + code TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (id, revision) + ); + CREATE INDEX IF NOT EXISTS playgrounds_created_at + ON playgrounds (created_at DESC); + `) + return err +} + +func (store *playgroundStore) save(ctx context.Context, id, title, code string) (playground, error) { + if id != "" && !playgroundIDPattern.MatchString(id) { + return playground{}, errors.New("invalid playground id") + } + if title == "" || utf8.RuneCountInString(title) > maximumTitleRunes { + return playground{}, fmt.Errorf("title must contain 1 to %d characters", maximumTitleRunes) + } + if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) { + return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes) + } + if id == "" { + var err error + id, err = randomPlaygroundID() + if err != nil { + return playground{}, err + } + } + + transaction, err := store.BeginTx(ctx, nil) + if err != nil { + return playground{}, err + } + defer transaction.Rollback() + + var revision int + if err := transaction.QueryRowContext( + ctx, + `SELECT COALESCE(MAX(revision), 0) + 1 FROM playgrounds WHERE id = ?`, + id, + ).Scan(&revision); err != nil { + return playground{}, err + } + createdAt := time.Now().UTC().Truncate(time.Millisecond) + if _, err := transaction.ExecContext( + ctx, + `INSERT INTO playgrounds (id, revision, title, code, created_at) VALUES (?, ?, ?, ?, ?)`, + id, + revision, + title, + code, + createdAt.Format(time.RFC3339Nano), + ); err != nil { + return playground{}, err + } + if err := transaction.Commit(); err != nil { + return playground{}, err + } + return playground{ID: id, Revision: revision, Title: title, Code: code, CreatedAt: createdAt}, nil +} + +func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) { + if !playgroundIDPattern.MatchString(id) || revision < 1 { + return playground{}, sql.ErrNoRows + } + var result playground + var createdAt string + err := store.QueryRowContext( + ctx, + `SELECT id, revision, title, code, created_at FROM playgrounds WHERE id = ? AND revision = ?`, + id, + revision, + ).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &createdAt) + if err != nil { + return playground{}, err + } + result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) + return result, err +} + +func randomPlaygroundID() (string, error) { + const alphabet = "abcdefghijkmnopqrstuvwxyz23456789" + result := make([]byte, 10) + for index := range result { + value, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return "", err + } + result[index] = alphabet[value.Int64()] + } + return string(result), nil +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..48be99e --- /dev/null +++ b/server/go.mod @@ -0,0 +1,5 @@ +module git.heaust.org/heaust/yawn/server + +go 1.19 + +require github.com/mattn/go-sqlite3 v1.14.32 diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..66f7516 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..47ac836 --- /dev/null +++ b/server/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + root, err := repositoryRoot() + if err != nil { + return err + } + + log.Print("building core and handles packages") + packages, err := buildPackages(root) + if err != nil { + return fmt.Errorf("build packages: %w", err) + } + log.Printf("loaded %d package assets into memory", len(packages)) + + databasePath := os.Getenv("YAWN_DATABASE") + if databasePath == "" { + databasePath = filepath.Join(root, "server", "data", "yawn.db") + } + store, err := openPlaygroundStore(databasePath) + if err != nil { + return fmt.Errorf("open playground database: %w", err) + } + defer store.Close() + + app, err := newApplication( + store, + packages, + filepath.Join(root, "node_modules", "monaco-editor", "min"), + ) + if err != nil { + return err + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + server := &http.Server{ + Addr: ":" + port, + Handler: app.routes(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + stopped := make(chan os.Signal, 1) + signal.Notify(stopped, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-stopped + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Printf("shutdown: %v", err) + } + }() + + log.Printf("Yawn server listening on http://0.0.0.0:%s", port) + err = server.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func repositoryRoot() (string, error) { + directory, err := os.Getwd() + if err != nil { + return "", err + } + for { + if fileExists(filepath.Join(directory, "Cargo.toml")) && + fileExists(filepath.Join(directory, "core", "Cargo.toml")) && + fileExists(filepath.Join(directory, "addons", "handles", "package.json")) { + return directory, nil + } + parent := filepath.Dir(directory) + if parent == directory { + return "", errors.New("could not find the Yawn repository root") + } + directory = parent + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/server/packages.go b/server/packages.go new file mode 100644 index 0000000..0b42874 --- /dev/null +++ b/server/packages.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "fmt" + "io/fs" + "mime" + "os" + "os/exec" + "path/filepath" + "time" +) + +type memoryAsset struct { + contentType string + content []byte + etag string + modified time.Time +} + +func buildPackages(root string) (map[string]memoryAsset, error) { + output, err := os.MkdirTemp("", "yawn-packages-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(output) + + if err := runBuild(root, "wasm-pack", "build", filepath.Join(root, "core"), + "--target", "web", "--out-dir", filepath.Join(output, "pkg"), "--release"); err != nil { + return nil, err + } + if err := runBuild(root, "node", filepath.Join(root, "server", "build.mjs"), root, output); err != nil { + return nil, err + } + + assets := make(map[string]memoryAsset) + for _, name := range []string{ + "core.js", + "handles.js", + "worker.js", + "importer-worker.js", + "bvh-worker.js", + } { + asset, err := loadMemoryAsset(filepath.Join(output, name)) + if err != nil { + return nil, err + } + assets[name] = asset + } + + err = filepath.WalkDir(filepath.Join(output, "pkg"), func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(filepath.Join(output, "pkg"), path) + if err != nil { + return err + } + asset, err := loadMemoryAsset(path) + if err != nil { + return err + } + assets[filepath.ToSlash(filepath.Join("pkg", relative))] = asset + return nil + }) + if err != nil { + return nil, err + } + return assets, nil +} + +func runBuild(directory, name string, arguments ...string) error { + command := exec.Command(name, arguments...) + command.Dir = directory + command.Env = os.Environ() + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Run(); err != nil { + return fmt.Errorf("%s: %w\n%s", name, err, output.String()) + } + return nil +} + +func loadMemoryAsset(path string) (memoryAsset, error) { + content, err := os.ReadFile(path) + if err != nil { + return memoryAsset{}, err + } + digest := sha256.Sum256(content) + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } + return memoryAsset{ + contentType: contentType, + content: content, + etag: fmt.Sprintf("\"%x\"", digest[:12]), + modified: time.Now().UTC(), + }, nil +} diff --git a/server/web/assets/docs.css b/server/web/assets/docs.css new file mode 100644 index 0000000..a7ff098 --- /dev/null +++ b/server/web/assets/docs.css @@ -0,0 +1,95 @@ +.docs-body { background: #fff; } +.docs-header { position: sticky; top: 0; width: 100%; height: 66px; padding: 0 28px; border-bottom: 1px solid #dbe7f0; background: rgb(255 255 255 / 94%); backdrop-filter: blur(14px); } +.docs-header .wordmark { font-size: 19px; } +.docs-product { margin-left: -22px; padding-left: 18px; color: #8190a1; border-left: 1px solid #d7e3ec; font-size: 13px; font-weight: 600; } +.docs-header .site-nav { flex: 0 0 auto; } +.docs-search { display: flex; width: min(370px, 35vw); height: 36px; align-items: center; gap: 9px; margin: 0 auto; padding: 0 10px; color: #8496a9; border: 1px solid #dbe6ef; border-radius: 8px; background: #f8fbfd; } +.docs-search input { min-width: 0; flex: 1; color: #243d57; border: 0; outline: 0; background: transparent; font-size: 12px; } +.docs-search kbd { padding: 2px 6px; color: #93a3b4; border: 1px solid #d4e0e9; border-radius: 4px; background: white; font: 10px/1.4 ui-monospace, monospace; } +.docs-layout { display: grid; grid-template-columns: 245px minmax(0, 790px) 180px; justify-content: center; gap: 58px; } +.docs-sidebar { position: sticky; top: 66px; height: calc(100vh - 66px); overflow: auto; padding: 35px 20px 35px 29px; border-right: 1px solid #e2ebf2; scrollbar-width: thin; } +.sidebar-group { display: grid; gap: 2px; margin-bottom: 27px; } +.sidebar-group > strong, .docs-toc > strong { margin-bottom: 8px; color: #8a9aaa; font-size: 9px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +.sidebar-group a { padding: 7px 10px; color: #64768a; border-radius: 6px; font-size: 12px; text-decoration: none; } +.sidebar-group a:hover { color: #1685cf; background: #f3f9fd; } +.sidebar-group a.active { color: #087fc9; background: #eaf7ff; font-weight: 650; } +.sidebar-playground { display: flex; align-items: center; gap: 11px; margin-top: 36px; padding: 13px; color: #155a87; border: 1px solid #cde5f5; border-radius: 9px; background: #f2faff; text-decoration: none; } +.sidebar-playground > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; color: white; background: #1b8fe5; font-size: 8px; } +.sidebar-playground div { display: grid; gap: 3px; } +.sidebar-playground strong { font-size: 11px; } +.sidebar-playground small { color: #7e99ad; font-size: 9px; } +.docs-main { min-width: 0; padding: 72px 0 150px; } +.docs-main article { min-width: 0; } +.docs-main section { padding-top: 80px; scroll-margin-top: 55px; } +.docs-main section:first-child { padding-top: 0; } +.docs-main section + section { margin-top: 20px; border-top: 1px solid #e5edf3; } +.doc-eyebrow { margin-bottom: 16px; color: #1788d2; font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.docs-main h1 { max-width: 700px; margin: 0 0 20px; color: #102a44; font-size: 48px; letter-spacing: -.055em; line-height: 1.08; } +.docs-main h2 { margin: 14px 0 19px; color: #112c47; font-size: 31px; letter-spacing: -.04em; line-height: 1.2; } +.docs-main h3 { margin: 32px 0 12px; color: #1b3751; font-size: 18px; letter-spacing: -.02em; } +.docs-main p, .docs-main li { color: #5f7083; font-size: 14px; line-height: 1.78; } +.docs-main p { margin: 0 0 19px; } +.docs-main strong { color: #314d67; } +.doc-lede { max-width: 730px; color: #50657a !important; font-size: 17px !important; line-height: 1.75 !important; } +.docs-main :not(pre) > code { padding: 2px 5px; color: #147ab9; border: 1px solid #d7e9f4; border-radius: 4px; background: #f3faff; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.section-kicker { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; color: #1587d0; font-size: 10px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; } +.section-kicker span { display: grid; width: 23px; height: 23px; place-items: center; border: 1px solid #bde1f7; border-radius: 50%; background: #eef9ff; font: 700 8px/1 ui-monospace, monospace; } +.callout { display: flex; gap: 14px; margin: 25px 0; padding: 18px 20px; border: 1px solid #e0e8ef; border-radius: 10px; background: #f9fbfd; } +.callout-blue { border-color: #cce8f8; background: #f1faff; } +.callout-icon { display: grid; width: 25px; height: 25px; flex: 0 0 auto; place-items: center; color: #1688d1; border: 1px solid #b9dff5; border-radius: 50%; font-size: 11px; font-weight: 800; } +.callout strong { display: block; margin-bottom: 3px; font-size: 12px; } +.callout p { margin: 0; font-size: 12px; line-height: 1.65; } +.code-block { overflow: hidden; margin: 23px 0 26px; border: 1px solid #d4e2ec; border-radius: 10px; background: #0c2037; box-shadow: 0 8px 24px rgb(30 65 94 / 7%); } +.code-title { display: flex; height: 38px; align-items: center; justify-content: space-between; padding: 0 12px 0 16px; color: #8299ae; border-bottom: 1px solid #233c54; background: #102840; font: 10px/1 ui-monospace, monospace; } +.code-title button { padding: 5px 8px; color: #91a9bc; border: 1px solid #2b4861; border-radius: 5px; background: #163149; cursor: pointer; font-size: 9px; } +.code-title button:hover { color: #bce7ff; border-color: #34729c; } +.code-block pre { margin: 0; padding: 19px 21px 22px; overflow: auto; } +.code-block code { color: #c8d8e6; font: 11.5px/1.75 ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; } +.code-block-large code { font-size: 12px; } +.explain-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 11px; margin: 24px 0; } +.explain-grid > div { padding: 16px; border: 1px solid #e0e9f0; border-radius: 9px; background: #fbfdfe; } +.explain-grid p { margin: 10px 0 0; font-size: 11px; line-height: 1.55; } +.inline-play { display: flex; align-items: center; gap: 14px; margin: 27px 0; padding: 18px; color: #185e8c; border: 1px solid #c8e5f6; border-radius: 11px; background: #f0f9ff; text-decoration: none; } +.inline-play > span { display: grid; width: 33px; height: 33px; place-items: center; border-radius: 50%; color: white; background: #198ddd; font-size: 10px; } +.inline-play > div { display: grid; flex: 1; gap: 3px; } +.inline-play strong { color: #185e8c; font-size: 12px; } +.inline-play small { color: #7598af; font-size: 10px; } +.inline-play b { font-size: 11px; } +.mental-model { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; align-items: center; gap: 11px; margin: 28px 0; } +.mental-model > div { display: grid; min-height: 85px; align-content: center; gap: 7px; padding: 13px; border: 1px solid #d9e6ef; border-radius: 8px; background: #f9fcfe; text-align: center; } +.mental-model strong { color: #31536e; font-size: 11px; } +.mental-model span { color: #8a9aaa; font: 8px/1.5 ui-monospace, monospace; } +.mental-model i { color: #8bbbd9; font-style: normal; } +.mental-model .mental-shared { border-color: #a9daf5; background: #edf8ff; } +.deploy-list { display: grid; gap: 0; margin: 20px 0 35px; padding: 0; list-style: none; } +.deploy-list li { display: flex; gap: 16px; padding: 18px 0; border-bottom: 1px solid #e5edf3; } +.deploy-list li > span { display: grid; width: 29px; height: 29px; flex: 0 0 auto; place-items: center; color: #1689d2; border: 1px solid #c3e2f5; border-radius: 50%; background: #f0f9ff; font: 700 10px/1 ui-monospace, monospace; } +.deploy-list strong { display: block; margin-bottom: 3px; font-size: 13px; } +.deploy-list p { margin: 0; font-size: 12px; } +.next-card { display: flex; align-items: center; justify-content: space-between; gap: 35px; margin-top: 48px; padding: 30px; color: white; border-radius: 13px; background: linear-gradient(120deg, #0c2945, #0d5586); } +.next-card small { color: #7dc8f2; font-size: 9px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +.next-card h3 { margin: 7px 0 4px; color: white; } +.next-card p { margin: 0; color: #a8c7da; font-size: 11px; } +.next-card .button { flex: 0 0 auto; } +.docs-toc { position: sticky; top: 66px; height: fit-content; display: grid; gap: 9px; padding-top: 47px; } +.docs-toc a { padding-left: 11px; color: #8795a5; border-left: 1px solid #dce6ed; font-size: 10px; line-height: 1.5; text-decoration: none; } +.docs-toc a:hover, .docs-toc a.active { color: #1789d2; border-color: #1789d2; } +.search-hidden { display: none !important; } + +@media (max-width: 1120px) { + .docs-layout { grid-template-columns: 235px minmax(0, 740px); gap: 40px; padding-right: 30px; } + .docs-toc { display: none; } +} +@media (max-width: 760px) { + .docs-header { padding-inline: 15px; } + .docs-product, .docs-header .site-nav { display: none; } + .docs-search { width: auto; flex: 1; margin-left: auto; } + .docs-layout { display: block; padding: 0 18px; } + .docs-sidebar { display: none; } + .docs-main { padding-top: 48px; } + .docs-main h1 { font-size: 39px; } + .docs-main h2 { font-size: 27px; } + .explain-grid, .mental-model { grid-template-columns: 1fr; } + .mental-model > i { transform: rotate(90deg); text-align: center; } + .next-card { align-items: flex-start; flex-direction: column; } +} diff --git a/server/web/assets/docs.js b/server/web/assets/docs.js new file mode 100644 index 0000000..382aa04 --- /dev/null +++ b/server/web/assets/docs.js @@ -0,0 +1,44 @@ +const search = document.querySelector(".docs-search input"); +const sections = [...document.querySelectorAll(".docs-main section[data-title]")]; +const navigationLinks = [...document.querySelectorAll(".docs-sidebar a[href^='#']")]; +const tocLinks = [...document.querySelectorAll(".docs-toc a")]; + +for (const button of document.querySelectorAll("[data-copy]")) { + button.addEventListener("click", async () => { + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + await navigator.clipboard.writeText(code); + const original = button.textContent; + button.textContent = "Copied"; + setTimeout(() => (button.textContent = original), 1200); + }); +} + +search?.addEventListener("input", () => { + const query = search.value.trim().toLowerCase(); + for (const section of sections) { + const matches = !query || section.textContent.toLowerCase().includes(query); + section.classList.toggle("search-hidden", !matches); + } +}); + +addEventListener("keydown", (event) => { + if (event.key === "/" && document.activeElement !== search) { + event.preventDefault(); + search?.focus(); + } +}); + +const activate = (id) => { + for (const link of [...navigationLinks, ...tocLinks]) { + link.classList.toggle("active", link.hash === `#${id}`); + } +}; + +const observer = new IntersectionObserver( + (entries) => { + const visible = entries.filter((entry) => entry.isIntersecting); + if (visible.length) activate(visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0].target.id); + }, + { rootMargin: "-70px 0px -72%", threshold: 0 }, +); +for (const section of sections) observer.observe(section); diff --git a/server/web/assets/playground.css b/server/web/assets/playground.css new file mode 100644 index 0000000..31def0f --- /dev/null +++ b/server/web/assets/playground.css @@ -0,0 +1,163 @@ +:root { + color: #17324c; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} +* { box-sizing: border-box; } +[hidden] { display: none !important; } +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } +button, input { font: inherit; } +button { color: inherit; } +.playground-app { display: grid; width: 100%; height: 100%; height: 100dvh; grid-template-rows: 58px 41px minmax(0, 1fr); background: #f7fbfe; } + +.playground-header { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 0 16px 0 19px; border-bottom: 1px solid #d5e3ed; background: #fff; } +.playground-brand { display: inline-flex; align-items: center; gap: 9px; color: #102d49; text-decoration: none; } +.playground-brand strong { font-size: 18px; letter-spacing: -.04em; } +.brand-mark { position: relative; display: inline-block; width: 22px; height: 22px; } +.brand-mark i { position: absolute; width: 7px; border: 2px solid #218ddd; border-radius: 7px; transform: rotate(-28deg); } +.brand-mark i:nth-child(1) { left: 1px; top: 9px; height: 10px; } +.brand-mark i:nth-child(2) { left: 7px; top: 5px; height: 14px; } +.brand-mark i:nth-child(3) { left: 14px; top: 1px; height: 18px; } +.header-divider { width: 1px; height: 22px; margin-inline: 2px; background: #d8e3eb; } +.project-name { display: flex; min-width: 0; align-items: center; gap: 7px; } +.project-name input { width: min(250px, 24vw); padding: 5px 7px; color: #29445e; border: 1px solid transparent; border-radius: 5px; outline: 0; background: transparent; font-size: 13px; font-weight: 650; text-overflow: ellipsis; } +.project-name input:hover { background: #f5f9fc; } +.project-name input:focus { border-color: #a9d8f5; background: #f7fcff; box-shadow: 0 0 0 2px rgb(50 166 232 / 10%); } +#dirty-indicator, #tab-dirty { display: none; width: 6px; height: 6px; border-radius: 50%; background: #2a9ee6; } +#dirty-indicator.visible, #tab-dirty.visible { display: inline-block; } +#revision-label { padding: 4px 7px; color: #74879a; border: 1px solid #dae5ed; border-radius: 4px; background: #f8fafc; font: 600 9px/1 ui-monospace, monospace; } +.playground-nav { display: flex; flex: 1; justify-content: flex-end; gap: 21px; } +.playground-nav a { color: #687a8c; font-size: 11px; font-weight: 600; text-decoration: none; } +.playground-nav a:hover { color: #1889d2; } +.tool-button, .run-button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 7px; padding: 0 12px; border-radius: 6px; cursor: pointer; font-size: 11px; font-weight: 650; } +.tool-button { color: #486177; border: 1px solid #d4e1ea; background: #fff; } +.tool-button:hover { color: #177fc2; border-color: #add5ee; background: #f6fbfe; } +.run-button { min-width: 111px; color: white; border: 1px solid #1687ce; background: #1a8fda; box-shadow: 0 4px 12px rgb(26 143 218 / 18%); } +.run-button:hover { background: #117fc5; } +.run-button:disabled, .tool-button:disabled { cursor: wait; opacity: .58; } +.run-button kbd { padding-left: 7px; color: rgb(255 255 255 / 70%); border: 0; border-left: 1px solid rgb(255 255 255 / 24%); font: 8px/1 ui-monospace, monospace; } + +.playground-tools { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 0 10px; border-bottom: 1px solid #cfdce6; background: #f6f9fb; } +.tool-group { display: flex; align-items: center; gap: 2px; } +.tool-group button { display: inline-flex; height: 29px; align-items: center; gap: 6px; padding: 0 9px; color: #586d80; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 10px; } +.tool-group button:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; } +.braces { color: #2498df; font: 700 10px/1 ui-monospace, monospace; } +.tool-context { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: center; gap: 7px; color: #748698; font-size: 9px; } +.language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; } +.context-divider { width: 1px; height: 12px; margin-inline: 4px; background: #cfdae3; } +#type-status { overflow: hidden; max-width: 250px; text-overflow: ellipsis; white-space: nowrap; } +.tool-group-right { margin-left: auto; } + +.workbench { --editor-width: 50%; display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(330px, var(--editor-width)) 5px minmax(330px, 1fr); background: #0b1725; } +.editor-pane, .stage-pane { min-width: 0; min-height: 0; } +.editor-pane { display: grid; grid-template-rows: 37px minmax(0, 1fr) 24px; } +.pane-tabs, .stage-toolbar { display: flex; min-width: 0; align-items: stretch; border-bottom: 1px solid #1d3245; background: #112233; } +.pane-tab { display: flex; min-width: 145px; align-items: center; gap: 7px; padding: 0 14px; color: #9db1c2; border: 0; border-right: 1px solid #22384b; background: #0d1d2d; font-size: 10px; } +.pane-tab.active { color: #d4e2ed; border-top: 2px solid #2ba5ea; background: #0b1927; } +.ts-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #fff; border-radius: 2px; background: #278dcd; font: 700 7px/1 ui-monospace, monospace; } +.editor { position: relative; min-height: 0; overflow: hidden; background: #0b1927; } +.editor-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; } +.monaco-editor .margin { background-color: #0b1927 !important; } +.editor-status { display: flex; align-items: center; gap: 14px; padding: 0 10px; color: #6f8599; border-top: 1px solid #1b3042; background: #0c1d2c; font: 8px/1 ui-monospace, monospace; } +.status-spacer { flex: 1; } +#editor-diagnostics { display: flex; align-items: center; gap: 5px; } +#editor-diagnostics i { width: 5px; height: 5px; border-radius: 50%; background: #4aa975; } +#editor-diagnostics.has-errors { color: #e8a4a4; } +#editor-diagnostics.has-errors i { background: #e26f6f; } +.resize-handle { position: relative; z-index: 3; cursor: col-resize; background: #172a3b; } +.resize-handle::after { position: absolute; top: 50%; left: 1px; width: 3px; height: 40px; transform: translateY(-50%); border-radius: 3px; background: #355067; content: ""; opacity: .65; } +.resize-handle:hover, .resize-handle.dragging { background: #218ed2; } + +.stage-pane { display: grid; grid-template-rows: 37px minmax(230px, 1fr) 190px; background: #091521; } +.stage-toolbar { justify-content: space-between; } +.stage-tabs button { height: 100%; padding: 0 14px; color: #8da3b6; border: 0; border-top: 2px solid transparent; background: transparent; font-size: 10px; } +.stage-tabs button.active { color: #d4e3ee; border-top-color: #35a9ed; background: #0d1d2c; } +.stage-meta { display: flex; min-width: 0; align-items: center; gap: 10px; padding-right: 12px; color: #6e869a; font: 8px/1 ui-monospace, monospace; } +#run-status { display: flex; align-items: center; gap: 6px; } +#run-status i { width: 6px; height: 6px; border-radius: 50%; background: #4cad79; box-shadow: 0 0 0 3px rgb(76 173 121 / 10%); } +#run-status.running i { background: #36a8e8; animation: status-pulse 1s infinite; } +#run-status.failed { color: #e49a9a; } +#run-status.failed i { background: #e36d6d; } +.stage-divider { width: 1px; height: 12px; background: #294052; } +.preview { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 45%, #102b43, #06111d 72%); } +.preview::before { position: absolute; inset: 0; background-image: linear-gradient(rgb(82 143 182 / 5%) 1px, transparent 1px), linear-gradient(90deg, rgb(82 143 182 / 5%) 1px, transparent 1px); background-size: 32px 32px; content: ""; pointer-events: none; } +.preview canvas { position: relative; display: block; width: 100%; height: 100%; object-fit: contain; touch-action: none; } +.preview-message { position: absolute; top: 50%; left: 50%; display: grid; justify-items: center; gap: 7px; transform: translate(-50%, -50%); color: #93aabd; text-align: center; pointer-events: none; } +.preview-message[hidden] { display: none; } +.preview-message-icon { display: grid; width: 43px; height: 43px; margin-bottom: 7px; place-items: center; color: #54bdf6; border: 1px solid #25577a; border-radius: 12px; background: #0b2941; box-shadow: 0 0 30px rgb(46 171 238 / 12%); font-size: 16px; font-weight: 800; } +.preview-message strong { color: #b9cddd; font-size: 11px; } +.preview-message small { color: #627a8f; font-size: 9px; } + +.inspector { min-height: 0; border-top: 1px solid #22384b; background: #0a1825; } +.inspector.collapsed { display: none; } +.stage-pane:has(.inspector.collapsed) { grid-template-rows: 37px minmax(230px, 1fr) 0; } +.inspector-tabs { display: flex; height: 31px; align-items: stretch; border-bottom: 1px solid #1c3041; background: #0e1e2c; } +.inspector-tabs button { padding: 0 13px; color: #71889b; border: 0; border-bottom: 1px solid transparent; background: transparent; cursor: pointer; font-size: 9px; } +.inspector-tabs button.active { color: #c0d2df; border-bottom-color: #32a8eb; } +.inspector-tabs button span { margin-left: 4px; padding: 1px 5px; border-radius: 5px; background: #1a3245; font-size: 7px; } +.inspector-tabs .clear-console { margin-left: auto; color: #61788b; } +.inspector-panel { display: none; height: calc(100% - 31px); overflow: auto; } +.inspector-panel.active { display: block; } +.console-empty, .profile-empty { display: flex; height: 100%; align-items: center; justify-content: center; gap: 10px; color: #718a9f; font: 9px/1 ui-monospace, monospace; } +.console-empty > span { color: #318fc7; font-size: 12px; } +.console-empty code { color: #90aabd; } +#console-lines { margin: 0; padding: 5px 0; color: #9cb1c1; list-style: none; font: 9px/1.6 ui-monospace, monospace; } +#console-lines li { display: flex; min-height: 22px; align-items: flex-start; gap: 9px; padding: 3px 10px; border-bottom: 1px solid rgb(36 57 73 / 45%); white-space: pre-wrap; word-break: break-word; } +#console-lines li::before { color: #399ed9; content: "›"; } +#console-lines li.error { color: #e19a9a; } +#console-lines li.error::before { color: #dd6b6b; content: "×"; } +.profile-empty { font-family: inherit; } +.profile-empty .pulse-icon { display: grid; width: 30px; height: 30px; place-items: center; color: #48ace4; border: 1px solid #24516f; border-radius: 50%; background: #0e2940; } +.profile-empty > div { display: grid; gap: 4px; } +.profile-empty strong { color: #91a9ba; font-size: 10px; } +.profile-empty small { color: #526b7e; font-size: 8px; } +.profile-results { padding: 10px 12px; color: #8fa6b8; } +.profile-summary { display: grid; grid-template-columns: .7fr .8fr 1.5fr; gap: 8px; margin-bottom: 9px; } +.profile-summary > div { display: grid; gap: 3px; padding: 7px 9px; border: 1px solid #1e384b; border-radius: 5px; background: #0e2130; } +.profile-summary small { color: #587185; font-size: 7px; text-transform: uppercase; } +.profile-summary strong { overflow: hidden; color: #9fc7de; font: 8px/1.3 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; } +.profile-results table { width: 100%; border-collapse: collapse; font: 8px/1.4 ui-monospace, monospace; } +.profile-results th, .profile-results td { padding: 5px 7px; border-bottom: 1px solid #1b3041; text-align: left; } +.profile-results th { color: #506b80; font-weight: 600; } +.profile-results td:last-child, .profile-results th:last-child { color: #56b4e8; text-align: right; } + +.snippets-dialog { width: min(580px, calc(100% - 30px)); padding: 0; color: #18354f; border: 1px solid #cadce8; border-radius: 14px; background: #fff; box-shadow: 0 30px 90px rgb(5 25 42 / 28%); } +.snippets-dialog::backdrop { background: rgb(6 23 38 / 48%); backdrop-filter: blur(3px); } +.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; padding: 25px 26px 17px; } +.dialog-heading span { color: #1b8ed8; font-size: 8px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +.dialog-heading h2 { margin: 5px 0 3px; font-size: 23px; letter-spacing: -.04em; } +.dialog-heading p { margin: 0; color: #798b9c; font-size: 10px; } +.dialog-heading > button { color: #7c8fa0; border: 0; background: transparent; cursor: pointer; font-size: 22px; } +.snippet-search { display: flex; height: 35px; align-items: center; gap: 8px; margin: 0 26px 14px; padding: 0 10px; color: #8a9aaa; border: 1px solid #d8e5ed; border-radius: 7px; background: #f9fbfd; } +.snippet-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 10px; } +.snippet-grid { display: grid; gap: 1px; padding: 0 16px 18px; } +.snippet-grid > button { display: grid; min-height: 66px; grid-template-columns: 40px 1fr auto; align-items: center; gap: 12px; padding: 8px 10px; text-align: left; border: 1px solid transparent; border-radius: 8px; background: transparent; cursor: pointer; } +.snippet-grid > button:hover { border-color: #cee5f4; background: #f3faff; } +.snippet-icon { display: grid; width: 36px; height: 36px; place-items: center; color: #168bd3; border: 1px solid #c6e4f6; border-radius: 9px; background: #edf9ff; font-size: 14px; } +.snippet-grid button > div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 3px 8px; } +.snippet-grid strong { color: #294861; font-size: 11px; } +.snippet-grid small { grid-column: 1; color: #7b8e9f; font-size: 9px; } +.snippet-grid code { grid-column: 2; grid-row: 1 / 3; justify-self: end; padding: 4px 6px; color: #3b82ac; border: 1px solid #d8e8f1; border-radius: 4px; background: #f9fcfe; font: 8px/1 ui-monospace, monospace; } +.snippet-grid b { color: #329cdc; font-size: 16px; } +.toast { position: fixed; z-index: 100; right: 18px; bottom: 18px; max-width: 360px; padding: 11px 15px; transform: translateY(20px); color: #d8e9f4; border: 1px solid #2d5875; border-radius: 7px; background: #102d42; box-shadow: 0 12px 35px rgb(2 17 29 / 24%); font-size: 10px; opacity: 0; pointer-events: none; transition: opacity 180ms ease, transform 180ms ease; } +.toast.visible { transform: none; opacity: 1; } +@keyframes status-pulse { 50% { opacity: .35; } } + +@media (max-width: 800px) { + .playground-header { gap: 8px; padding-inline: 10px; } + .header-divider, .playground-nav, #share-button, .project-name #revision-label { display: none; } + .project-name { min-width: 0; flex: 1; } + .project-name input { width: 100%; } + .tool-button { padding-inline: 8px; } + .run-button { min-width: auto; } + .run-button kbd { display: none; } + .tool-context { display: none; } + .tool-group-right { margin-left: auto; } + .workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: minmax(400px, 55vh) minmax(450px, 1fr); } + .resize-handle { display: none; } + .stage-pane { min-height: 500px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/server/web/assets/playground.js b/server/web/assets/playground.js new file mode 100644 index 0000000..4053376 --- /dev/null +++ b/server/web/assets/playground.js @@ -0,0 +1,625 @@ +const defaultSource = `import { + ArcRotateCamera, + ColorGrading, + FXAA, + Mesh, + PBRMaterial, + PointLight, + Scene, +} from "@yawn/handles"; + +const scene = new Scene(canvas, { hdr: true, fps: 60 }); +await scene.ready; + +const material = new PBRMaterial(scene, { + baseColor: [0.12, 0.58, 1, 1], + metallic: 0.15, + roughness: 0.35, +}); +await material.ready; + +const mesh = new Mesh(scene, { + material, + vertexData: { + positions: [-0.72, -0.62, 0, 0.72, -0.62, 0, 0, 0.76, 0], + normals: [0, 0, 1, 0, 0, 1, 0, 0, 1], + indices: [0, 1, 2], + }, +}); +await mesh.ready; + +log("Scene ready — drag your pointer across the preview."); +const move = (event: PointerEvent) => { + const bounds = canvas.getBoundingClientRect(); + mesh.position.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.2; + mesh.position.y = (0.5 - (event.clientY - bounds.top) / bounds.height) * 0.8; +}; +canvas.addEventListener("pointermove", move); + +export default { + scene, + mesh, + dispose() { + canvas.removeEventListener("pointermove", move); + scene.dispose(); + }, +};`; + +const snippets = { + camera: `// Orbit camera — drag to orbit and use the wheel to zoom. +const camera = new ArcRotateCamera(scene, { + target: mesh, + alpha: 0, + beta: Math.PI / 2, + radius: 3, + controls: { element: canvas, pointer: true }, +}); +await camera.ready;`, + light: `// A warm point light backed by shared rows. +const light = new PointLight(scene, { + position: [0, 0.7, 1], + color: [1, 0.68, 0.4], + intensity: 12, + range: 8, +}); +await light.ready;`, + instances: `// Clones share geometry until one clone changes its vertex data. +for (let x = -2; x <= 2; x++) { + const instance = mesh.clone({ position: [x * 0.35, 0, 0] }); + await instance.ready; +}`, + rows: `// Application-owned hot data in the same shared arena. +const velocity = await scene.ensureRows("app.velocity", 1024, 16, "f32"); +velocity.row(0).set([1, 0, 0, 0]); +log("velocity[0]", velocity.read(0));`, + post: `// Batch graph changes so the loadout is rebuilt once. +await scene.batchGraphUpdates(async () => { + const grade = new ColorGrading(scene, { toneMap: "aces", amount: 1 }); + const fxaa = new FXAA(scene); + await Promise.all([grade.ready, fxaa.ready]); +});`, +}; + +const elements = Object.fromEntries( + [ + "canvas", + "clear-console", + "console-count", + "console-empty", + "console-lines", + "cursor-status", + "dirty-indicator", + "editor", + "editor-diagnostics", + "format-button", + "fps-status", + "fullscreen-button", + "inspector", + "inspector-button", + "preview", + "preview-message", + "profile-adapter", + "profile-empty", + "profile-latency", + "profile-passes", + "profile-results", + "profile-total", + "project-title", + "reset-button", + "resize-handle", + "resolution-status", + "revision-label", + "run-button", + "run-status", + "save-button", + "share-button", + "snippets-button", + "snippets-dialog", + "tab-dirty", + "toast", + "type-status", + "workbench", + ].map((id) => [id, document.getElementById(id)]), +); + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const packages = Promise.all([import("/pkg/handles.js"), import("/pkg/core.js")]).then( + ([handles, core]) => ({ ...handles, ...core }), +); +let monaco; +let editor; +let model; +let current; +let currentID = ""; +let currentRevision = 0; +let initialSource = defaultSource; +let generation = 0; +let dirty = false; +let activePanel = "console"; +let stopProfile; +let profileCore; +let toastTimer; +let frameRequest; +let sampledFrame = 0; +let sampledAt = 0; + +function savedLocation() { + const match = location.pathname.match(/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/); + return match ? { id: match[1], revision: Number(match[2]) } : undefined; +} + +async function loadInitialPlayground() { + const saved = savedLocation(); + if (saved) { + try { + const response = await fetch(`/api/playgrounds/${saved.id}/${saved.revision}`); + if (!response.ok) throw new Error("This saved playground could not be found."); + const result = await response.json(); + currentID = result.id; + currentRevision = result.revision; + elements["project-title"].value = result.title; + elements["revision-label"].textContent = `Revision ${result.revision}`; + elements["share-button"].hidden = false; + initialSource = result.code; + document.title = `${result.title} — Yawn Playground`; + return result.code; + } catch (error) { + history.replaceState(null, "", "/playground"); + setRunStatus(error.message, "failed"); + showToast(error.message); + } + } + return localStorage.getItem("yawn:playground:draft") || defaultSource; +} + +function loadMonaco() { + return new Promise((resolve, reject) => { + if (!window.require?.config) { + reject(new Error("The TypeScript editor could not be loaded.")); + return; + } + const monacoBase = `${window.location.origin}/assets/monaco/`; + const workerSource = ` +self.MonacoEnvironment = { baseUrl: ${JSON.stringify(monacoBase)} }; +importScripts(${JSON.stringify(`${monacoBase}vs/base/worker/workerMain.js`)}); +`; + const workerURL = URL.createObjectURL( + new Blob([workerSource], { type: "text/javascript" }), + ); + window.MonacoEnvironment = { getWorkerUrl: () => workerURL }; + window.require.config({ paths: { vs: `${monacoBase}vs` } }); + window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject); + }); +} + +async function createEditor(source) { + monaco = await loadMonaco(); + const types = await fetch("/assets/yawn.d.ts").then((response) => response.text()); + const defaults = monaco.languages.typescript.typescriptDefaults; + defaults.setEagerModelSync(true); + defaults.setCompilerOptions({ + allowNonTsExtensions: true, + module: monaco.languages.typescript.ModuleKind.ESNext, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + noEmit: false, + strict: true, + target: monaco.languages.typescript.ScriptTarget.ESNext, + }); + defaults.setDiagnosticsOptions({ noSemanticValidation: false, noSyntaxValidation: false }); + defaults.addExtraLib(types, "file:///types/yawn.d.ts"); + monaco.editor.defineTheme("yawn", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "comment", foreground: "658198", fontStyle: "italic" }, + { token: "keyword", foreground: "C792EA" }, + { token: "string", foreground: "8FD5B6" }, + { token: "number", foreground: "F6AD7B" }, + { token: "type.identifier", foreground: "69C7F4" }, + ], + colors: { + "editor.background": "#0B1927", + "editor.foreground": "#C8D8E5", + "editor.lineHighlightBackground": "#102235", + "editor.selectionBackground": "#1D5B8066", + "editorCursor.foreground": "#55BDF4", + "editorGutter.background": "#0B1927", + "editorLineNumber.foreground": "#405B70", + "editorLineNumber.activeForeground": "#89A4B8", + "editorIndentGuide.background1": "#1B3143", + "editorWidget.background": "#102538", + "editorSuggestWidget.background": "#102538", + }, + }); + elements.editor.textContent = ""; + model = monaco.editor.createModel(source, "typescript", monaco.Uri.parse("file:///playground/scene.ts")); + editor = monaco.editor.create(elements.editor, { + model, + theme: "yawn", + automaticLayout: true, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontLigatures: true, + fontSize: 12, + lineHeight: 20, + minimap: { enabled: false }, + padding: { top: 12, bottom: 12 }, + renderLineHighlight: "all", + scrollBeyondLastLine: false, + smoothScrolling: true, + tabSize: 2, + wordWrap: "off", + }); + editor.addAction({ + id: "yawn.run", + label: "Run playground", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + run, + }); + editor.onDidChangeCursorPosition(({ position }) => { + elements["cursor-status"].textContent = `Ln ${position.lineNumber}, Col ${position.column}`; + }); + model.onDidChangeContent(() => { + markDirty(true); + if (!currentID) localStorage.setItem("yawn:playground:draft", model.getValue()); + }); + monaco.editor.onDidChangeMarkers(([resource]) => { + if (resource.toString() === model.uri.toString()) updateDiagnostics(); + }); + elements["type-status"].textContent = "TypeScript language service ready"; + updateDiagnostics(); +} + +function updateDiagnostics() { + if (!monaco || !model) return; + const markers = monaco.editor.getModelMarkers({ resource: model.uri }); + const errors = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Error).length; + const warnings = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Warning).length; + const total = errors + warnings; + elements["editor-diagnostics"].classList.toggle("has-errors", errors > 0); + elements["editor-diagnostics"].lastChild.textContent = total + ? ` ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}` + : " No problems"; + elements["type-status"].textContent = errors + ? `${errors} TypeScript error${errors === 1 ? "" : "s"}` + : "TypeScript language service ready"; +} + +function markDirty(value) { + dirty = value; + elements["dirty-indicator"].classList.toggle("visible", value); + elements["tab-dirty"].classList.toggle("visible", value); +} + +async function transpile() { + const worker = await monaco.languages.typescript.getTypeScriptWorker(); + const client = await worker(model.uri); + const emitted = await client.getEmitOutput(model.uri.toString()); + const output = emitted.outputFiles.find((file) => file.name.endsWith(".js")); + if (emitted.emitSkipped || !output) throw new Error("TypeScript could not emit this scene."); + return output.text + .replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "") + .replace(/^\s*export\s*\{\s*\};?\s*$/gm, "") + .replace(/\bexport\s+default\s+/, "return "); +} + +async function disposeCurrent() { + stopProfile?.(); + stopProfile = undefined; + if (profileCore) { + try { + await profileCore.setProfiler(false); + } catch { + // A disposed core cannot disable a profiler that is already gone. + } + profileCore = undefined; + } + const value = current; + current = undefined; + delete window.__yawnPlayground; + if (!value) return; + if (typeof value === "function") await value(); + else if (typeof value.dispose === "function") await value.dispose(); + else if (value.scene?.dispose) value.scene.dispose(); +} + +function replaceCanvas() { + const previous = elements.canvas; + const canvas = document.createElement("canvas"); + canvas.id = "canvas"; + canvas.setAttribute("aria-label", "Yawn WebGPU preview"); + const scale = Math.min(devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.round(elements.preview.clientWidth * scale)); + canvas.height = Math.max(1, Math.round(elements.preview.clientHeight * scale)); + previous.replaceWith(canvas); + elements.canvas = canvas; + elements["resolution-status"].textContent = `${canvas.width} × ${canvas.height}`; + return canvas; +} + +async function run() { + if (!editor || elements["run-button"].disabled) return; + const runID = ++generation; + elements["run-button"].disabled = true; + elements["run-button"].lastChild.textContent = " Running"; + clearConsole(); + setRunStatus("Compiling", "running"); + showPreviewMessage("Compiling TypeScript", "Checking types and preparing the render graph."); + try { + if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled on this page."); + if (!navigator.gpu) throw new Error("WebGPU is not available in this browser."); + const [code, api] = await Promise.all([transpile(), packages]); + await disposeCurrent(); + const canvas = replaceCanvas(); + const names = Object.keys(api); + const started = performance.now(); + const result = await new AsyncFunction(...names, "canvas", "log", code)( + ...names.map((name) => api[name]), + canvas, + (...values) => appendConsole(values.map(formatValue).join(" ")), + ); + if (runID !== generation) { + if (result?.dispose) await result.dispose(); + return; + } + current = result; + window.__yawnPlayground = result; + elements["preview-message"].hidden = true; + setRunStatus(`Running · ${Math.round(performance.now() - started)} ms`, "ready"); + if (activePanel === "profile") await attachProfiler(); + } catch (error) { + if (runID === generation) { + const message = error instanceof Error ? error.message : String(error); + appendConsole(message, true); + setRunStatus("Run failed", "failed"); + showPreviewMessage("Scene could not start", message); + openInspector("console"); + } + } finally { + if (runID === generation) { + elements["run-button"].disabled = false; + elements["run-button"].innerHTML = ' Run Ctrl ↵'; + } + } +} + +function currentCore() { + return current?.scene?.core ?? current?.core; +} + +async function attachProfiler() { + stopProfile?.(); + stopProfile = undefined; + profileCore = currentCore(); + if (!profileCore?.onProfile || !profileCore?.setProfiler) { + setProfileMessage("Run a scene to begin profiling."); + return; + } + stopProfile = profileCore.onProfile(renderProfile); + const supported = await profileCore.setProfiler(true); + if (!supported) setProfileMessage("Timestamp queries are not supported by this GPU adapter."); +} + +function setProfileMessage(message) { + elements["profile-empty"].querySelector("strong").textContent = message; + elements["profile-empty"].hidden = false; + elements["profile-results"].hidden = true; +} + +function renderProfile(profile) { + elements["profile-empty"].hidden = true; + elements["profile-results"].hidden = false; + elements["profile-total"].textContent = `${profile.milliseconds.toFixed(2)} ms`; + elements["profile-latency"].textContent = `${profile.readbackMilliseconds.toFixed(2)} ms`; + elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter"; + elements["profile-adapter"].title = profile.adapter || "WebGPU adapter"; + elements["profile-passes"].replaceChildren( + ...profile.passes.map((pass) => { + const row = document.createElement("tr"); + const name = document.createElement("td"); + const time = document.createElement("td"); + name.textContent = pass.name; + time.textContent = `${pass.milliseconds.toFixed(2)} ms`; + row.append(name, time); + return row; + }), + ); +} + +function formatValue(value) { + if (typeof value === "string") return value; + if (value instanceof Error) return value.message; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function appendConsole(message, error = false) { + elements["console-empty"].hidden = true; + const line = document.createElement("li"); + line.textContent = message; + line.classList.toggle("error", error); + elements["console-lines"].append(line); + elements["console-count"].textContent = elements["console-lines"].children.length; + line.scrollIntoView({ block: "nearest" }); +} + +function clearConsole() { + elements["console-lines"].replaceChildren(); + elements["console-empty"].hidden = false; + elements["console-count"].textContent = "0"; +} + +function setRunStatus(message, state) { + elements["run-status"].className = state === "running" ? "running" : state === "failed" ? "failed" : ""; + elements["run-status"].lastChild.textContent = ` ${message}`; +} + +function showPreviewMessage(title, detail) { + elements["preview-message"].hidden = false; + elements["preview-message"].querySelector("strong").textContent = title; + elements["preview-message"].querySelector("small").textContent = detail; +} + +function openInspector(panel) { + activePanel = panel; + elements.inspector.classList.remove("collapsed"); + elements["inspector-button"].setAttribute("aria-pressed", "true"); + for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) { + const active = button.dataset.panel === panel; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", String(active)); + } + for (const target of document.querySelectorAll(".inspector-panel")) { + target.classList.toggle("active", target.id === `${panel}-panel`); + } + if (panel === "profile") void attachProfiler(); + else if (profileCore) { + stopProfile?.(); + stopProfile = undefined; + void profileCore.setProfiler(false).catch(() => undefined); + profileCore = undefined; + } +} + +async function save() { + if (!editor || elements["save-button"].disabled) return; + elements["save-button"].disabled = true; + try { + const response = await fetch("/api/playgrounds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: currentID, + title: elements["project-title"].value.trim() || "Untitled playground", + code: model.getValue(), + }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "The playground could not be saved."); + currentID = result.id; + currentRevision = result.revision; + initialSource = result.code; + history.pushState(null, "", `/playground/${result.id}/${result.revision}`); + elements["revision-label"].textContent = `Revision ${result.revision}`; + elements["share-button"].hidden = false; + localStorage.removeItem("yawn:playground:draft"); + markDirty(false); + showToast(`Saved revision ${result.revision}`); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error)); + } finally { + elements["save-button"].disabled = false; + } +} + +function showToast(message) { + clearTimeout(toastTimer); + elements.toast.textContent = message; + elements.toast.classList.add("visible"); + toastTimer = setTimeout(() => elements.toast.classList.remove("visible"), 2400); +} + +function insertSnippet(name) { + const selection = editor.getSelection(); + const prefix = model.getValueInRange(selection).endsWith("\n") ? "" : "\n\n"; + editor.executeEdits("snippet", [{ range: selection, text: `${prefix}${snippets[name]}\n`, forceMoveMarkers: true }]); + elements["snippets-dialog"].close(); + editor.focus(); +} + +function sampleFPS(now = performance.now()) { + try { + const core = currentCore(); + if (!core) throw new Error(); + const frame = Number(core.array("signals").row(0)[1]); + if (!sampledAt || frame < sampledFrame) { + sampledAt = now; + sampledFrame = frame; + } else if (now - sampledAt >= 500) { + const fps = ((frame - sampledFrame) * 1000) / (now - sampledAt); + elements["fps-status"].textContent = `${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`; + sampledAt = now; + sampledFrame = frame; + } + } catch { + sampledAt = now; + sampledFrame = 0; + elements["fps-status"].textContent = "0 FPS"; + } + frameRequest = requestAnimationFrame(sampleFPS); +} + +elements["run-button"].addEventListener("click", run); +elements["save-button"].addEventListener("click", save); +elements["share-button"].addEventListener("click", async () => { + await navigator.clipboard.writeText(location.href); + showToast("Playground URL copied"); +}); +elements["project-title"].addEventListener("input", () => markDirty(true)); +elements["reset-button"].addEventListener("click", () => { + model?.setValue(initialSource); + void run(); +}); +elements["format-button"].addEventListener("click", () => editor?.getAction("editor.action.formatDocument").run()); +elements["snippets-button"].addEventListener("click", () => elements["snippets-dialog"].showModal()); +elements["snippets-dialog"].querySelector("[data-close-dialog]").addEventListener("click", () => elements["snippets-dialog"].close()); +elements["snippets-dialog"].addEventListener("click", (event) => { + if (event.target === elements["snippets-dialog"]) elements["snippets-dialog"].close(); +}); +for (const button of document.querySelectorAll("[data-snippet]")) { + button.addEventListener("click", () => insertSnippet(button.dataset.snippet)); +} +elements["snippets-dialog"].querySelector("input").addEventListener("input", (event) => { + const query = event.target.value.trim().toLowerCase(); + for (const button of document.querySelectorAll("[data-snippet]")) { + button.hidden = Boolean(query) && !button.textContent.toLowerCase().includes(query); + } +}); +for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) { + button.addEventListener("click", () => openInspector(button.dataset.panel)); +} +elements["clear-console"].addEventListener("click", clearConsole); +elements["inspector-button"].addEventListener("click", () => { + const collapsed = elements.inspector.classList.toggle("collapsed"); + elements["inspector-button"].setAttribute("aria-pressed", String(!collapsed)); +}); +elements["fullscreen-button"].addEventListener("click", () => { + if (document.fullscreenElement) void document.exitFullscreen(); + else void document.documentElement.requestFullscreen(); +}); + +elements["resize-handle"].addEventListener("pointerdown", (event) => { + if (innerWidth <= 800) return; + elements["resize-handle"].setPointerCapture(event.pointerId); + elements["resize-handle"].classList.add("dragging"); +}); +elements["resize-handle"].addEventListener("pointermove", (event) => { + if (!elements["resize-handle"].hasPointerCapture(event.pointerId)) return; + const bounds = elements.workbench.getBoundingClientRect(); + const percentage = Math.min(70, Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100)); + elements.workbench.style.setProperty("--editor-width", `${percentage}%`); +}); +elements["resize-handle"].addEventListener("pointerup", (event) => { + elements["resize-handle"].releasePointerCapture(event.pointerId); + elements["resize-handle"].classList.remove("dragging"); +}); +addEventListener("popstate", () => location.reload()); +addEventListener("beforeunload", () => { + cancelAnimationFrame(frameRequest); + void disposeCurrent(); +}); + +try { + const source = await loadInitialPlayground(); + await createEditor(source); + markDirty(false); + sampleFPS(); + await run(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + elements.editor.textContent = message; + setRunStatus("Editor failed", "failed"); + showPreviewMessage("Editor could not start", message); +} diff --git a/server/web/assets/site.css b/server/web/assets/site.css new file mode 100644 index 0000000..a0d920c --- /dev/null +++ b/server/web/assets/site.css @@ -0,0 +1,254 @@ +:root { + --ink: #10233e; + --muted: #5c6d82; + --line: #dfe9f2; + --line-strong: #cbdbe9; + --sky-25: #f8fcff; + --sky-50: #f0f9ff; + --sky-100: #e0f2fe; + --sky-200: #bae6fd; + --sky-400: #38bdf8; + --sky-500: #0ea5e9; + --sky-600: #0284c7; + --blue: #1b8fe5; + --blue-deep: #0d6fbd; + --navy: #0a1c35; + --radius: 16px; + --shadow-sm: 0 1px 2px rgb(18 49 77 / 4%), 0 8px 24px rgb(18 49 77 / 5%); + color: var(--ink); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; background: white; color: var(--ink); } +a { color: inherit; } +button, input { font: inherit; } +.section-shell { width: min(1180px, calc(100% - 48px)); margin-inline: auto; } + +.site-header { + position: relative; + z-index: 20; + display: flex; + width: min(1240px, calc(100% - 48px)); + height: 76px; + margin: 0 auto; + align-items: center; + gap: 36px; + border-bottom: 1px solid var(--line); +} +.wordmark { + display: inline-flex; + align-items: center; + gap: 11px; + color: #102945; + font-size: 21px; + font-weight: 750; + letter-spacing: -0.04em; + text-decoration: none; +} +.wordmark-mark { position: relative; display: inline-block; width: 25px; height: 25px; transform: rotate(-2deg); } +.wordmark-mark i { position: absolute; display: block; width: 8px; border: 2.5px solid #218ddd; border-radius: 8px; transform: rotate(-28deg); } +.wordmark-mark i:nth-child(1) { left: 2px; top: 10px; height: 12px; } +.wordmark-mark i:nth-child(2) { left: 9px; top: 5px; height: 17px; } +.wordmark-mark i:nth-child(3) { left: 16px; top: 1px; height: 21px; } +.site-nav { display: flex; flex: 1; align-items: center; justify-content: center; gap: 34px; } +.site-nav a, .site-footer nav a { color: #53667c; font-size: 14px; font-weight: 550; text-decoration: none; } +.site-nav a:hover, .site-footer nav a:hover { color: var(--blue); } +.button { + display: inline-flex; + min-height: 46px; + align-items: center; + justify-content: center; + gap: 14px; + padding: 0 21px; + color: white; + border: 1px solid var(--blue); + border-radius: 9px; + background: var(--blue); + box-shadow: 0 8px 20px rgb(27 143 229 / 16%); + font-size: 14px; + font-weight: 700; + text-decoration: none; + transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease; +} +.button:hover { transform: translateY(-1px); background: var(--blue-deep); box-shadow: 0 11px 25px rgb(27 143 229 / 22%); } +.button-small { min-height: 38px; padding: 0 17px; font-size: 13px; } +.button-quiet { color: #294661; border-color: var(--line-strong); background: white; box-shadow: none; } +.button-quiet:hover { color: var(--blue); background: var(--sky-50); box-shadow: none; } + +.hero { + position: relative; + display: grid; + min-height: 660px; + grid-template-columns: minmax(0, .9fr) minmax(540px, 1.1fr); + align-items: center; + gap: 66px; + padding-top: 60px; + padding-bottom: 82px; +} +.hero::before { + position: absolute; + z-index: -1; + top: 20px; + left: 32%; + width: min(600px, 68%); + height: 480px; + border-radius: 50%; + background: radial-gradient(circle, rgb(224 242 254 / 85%), transparent 67%); + content: ""; + filter: blur(10px); +} +.eyebrow { display: flex; align-items: center; gap: 9px; color: var(--sky-600); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.eyebrow > span { width: 22px; height: 2px; border-radius: 2px; background: var(--sky-500); } +.hero h1 { max-width: 670px; margin: 24px 0 24px; color: #0e2743; font-size: clamp(48px, 5.4vw, 71px); font-weight: 730; letter-spacing: -.06em; line-height: 1.02; } +.hero-lede { max-width: 580px; margin: 0; color: var(--muted); font-size: 18px; line-height: 1.72; } +.hero-actions { display: flex; gap: 12px; margin-top: 31px; } +.hero-note { display: flex; align-items: center; gap: 11px; margin-top: 27px; color: #7b8b9e; font-size: 12px; } +.browser-dots { display: flex; gap: 3px; } +.browser-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--sky-400); } +.browser-dots i:nth-child(2) { opacity: .55; } +.browser-dots i:nth-child(3) { opacity: .28; } + +.hero-demo { position: relative; overflow: hidden; min-width: 0; border: 1px solid #cee0ed; border-radius: 16px; background: #fff; box-shadow: 0 30px 70px rgb(22 66 102 / 15%), 0 4px 12px rgb(22 66 102 / 7%); transform: perspective(1400px) rotateY(-2.5deg) rotateX(1deg); } +.hero-demo::after { position: absolute; right: -100px; bottom: -100px; width: 250px; height: 250px; border-radius: 50%; background: rgb(56 189 248 / 10%); content: ""; filter: blur(35px); } +.demo-toolbar { display: flex; height: 44px; align-items: center; justify-content: space-between; padding: 0 16px; color: #7890a6; border-bottom: 1px solid #dbe8f1; background: #f8fbfd; font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.demo-toolbar span { display: flex; align-items: center; gap: 7px; } +.demo-toolbar > span > i { width: 7px; height: 7px; border-radius: 2px; background: #49aef1; } +.demo-toolbar .demo-status { color: #479165; } +.demo-toolbar .demo-status i { border-radius: 50%; background: #38b76b; box-shadow: 0 0 0 3px rgb(56 183 107 / 12%); } +.hero-demo pre { min-height: 355px; margin: 0; padding: 25px 27px 28px; overflow: auto; color: #405670; background: #fbfdff; font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; tab-size: 2; } +.syntax-purple { color: #9a61d2; } +.syntax-blue { color: #207fca; } +.syntax-green { color: #268a71; } +.syntax-orange { color: #c56b2d; } +.demo-preview { position: relative; height: 166px; overflow: hidden; border-top: 1px solid #c9d9e5; background: linear-gradient(145deg, #0a1e38, #0d294b); } +.demo-grid { position: absolute; inset: 0; opacity: .25; background-image: linear-gradient(rgb(105 190 249 / 30%) 1px, transparent 1px), linear-gradient(90deg, rgb(105 190 249 / 30%) 1px, transparent 1px); background-size: 30px 30px; transform: perspective(400px) rotateX(64deg) scale(1.4) translateY(26px); } +.demo-glow { position: absolute; top: 48%; left: 50%; width: 190px; height: 80px; transform: translate(-50%, -50%); border-radius: 50%; background: #3eb9ff; opacity: .3; filter: blur(35px); } +.demo-triangle { position: absolute; top: 25px; left: 50%; width: 125px; height: 112px; transform: translateX(-50%); background: linear-gradient(150deg, #a8e5ff, #258fdf 75%); clip-path: polygon(50% 0, 100% 100%, 0 100%); filter: drop-shadow(0 16px 16px rgb(46 168 240 / 25%)); } +.demo-preview > span { position: absolute; right: 12px; bottom: 10px; color: #7494b0; font: 9px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; } + +.trust-strip { display: grid; grid-template-columns: repeat(4, 1fr); border-block: 1px solid var(--line); background: var(--sky-25); } +.trust-strip div { display: flex; min-height: 92px; align-items: center; justify-content: center; gap: 10px; border-right: 1px solid var(--line); } +.trust-strip div:last-child { border-right: 0; } +.trust-strip strong { color: #143757; font-size: 16px; letter-spacing: -.03em; } +.trust-strip span { color: #718196; font-size: 12px; } + +.progression { padding-top: 115px; padding-bottom: 125px; } +.section-heading { max-width: 690px; margin-bottom: 50px; } +.section-heading h2, .architecture-copy h2, .cta-section h2 { margin: 16px 0; font-size: clamp(36px, 4vw, 48px); font-weight: 700; letter-spacing: -.05em; line-height: 1.12; } +.section-heading > p, .architecture-copy > p, .cta-section p { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.75; } +.gear-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 17px; } +.gear-card { position: relative; min-height: 390px; padding: 29px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius); background: white; box-shadow: var(--shadow-sm); } +.gear-card::before { position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(145deg, var(--sky-50), transparent 42%); content: ""; opacity: 0; transition: opacity .2s ease; } +.gear-card:hover::before { opacity: 1; } +.gear-card > * { position: relative; } +.gear-card-featured { border-color: #b9ddf6; background: linear-gradient(145deg, #f8fcff, white); } +.gear-number { position: absolute; top: 25px; right: 27px; color: #bdd1e0; font: 600 11px/1 ui-monospace, monospace; } +.gear-icon { display: grid; width: 52px; height: 52px; margin: 6px 0 33px; place-items: center; border: 1px solid #c6e5f8; border-radius: 13px; background: #eff9ff; } +.gear-card h3 { margin: 0 0 14px; color: #13324f; font-size: 20px; letter-spacing: -.025em; } +.gear-card p { margin: 0 0 25px; color: #6a7b8e; font-size: 14px; line-height: 1.72; } +.gear-card a { position: absolute; bottom: 28px; color: var(--blue-deep); font-size: 13px; font-weight: 700; text-decoration: none; } +.gear-card a span { margin-left: 5px; } +.cube-icon { width: 21px; height: 21px; border: 2px solid #258fd9; transform: rotate(30deg) skew(-4deg); } +.rows-icon { width: 24px; height: 19px; background: repeating-linear-gradient(to bottom, #258fd9 0 2px, transparent 2px 6px); } +.graph-icon { position: relative; width: 26px; height: 22px; background: linear-gradient(28deg, transparent 47%, #258fd9 48% 53%, transparent 54%); } +.graph-icon::before, .graph-icon::after { position: absolute; width: 7px; height: 7px; border: 2px solid #258fd9; border-radius: 50%; background: #eff9ff; content: ""; } +.graph-icon::before { left: 0; bottom: 0; } +.graph-icon::after { top: 0; right: 0; } + +.architecture-section { overflow: hidden; padding: 110px 0; color: #d7e9f8; background: radial-gradient(circle at 80% 30%, #153d67, transparent 36%), linear-gradient(145deg, #07192e, #0b2746); } +.architecture-grid { display: grid; grid-template-columns: .9fr 1.1fr; align-items: center; gap: 100px; } +.eyebrow-light { color: #65c9ff; } +.architecture-copy h2, .cta-section h2 { color: #f2f9ff; } +.architecture-copy > p { color: #9ab2c8; } +.check-list { display: grid; gap: 14px; margin: 30px 0; padding: 0; list-style: none; } +.check-list li { color: #bed1e2; font-size: 14px; } +.check-list li span { display: inline-grid; width: 20px; height: 20px; margin-right: 10px; place-items: center; border-radius: 50%; color: #6dd0ff; background: rgb(56 189 248 / 12%); font-size: 10px; } +.text-link-light { color: #6ed0ff; font-size: 13px; font-weight: 700; text-decoration: none; } +.architecture-visual { position: relative; display: flex; min-height: 480px; flex-direction: column; align-items: center; justify-content: center; } +.architecture-visual::before { position: absolute; width: 470px; height: 470px; border: 1px solid rgb(106 193 245 / 8%); border-radius: 50%; content: ""; box-shadow: 0 0 0 70px rgb(106 193 245 / 3%), 0 0 0 140px rgb(106 193 245 / 2%); } +.flow-label { position: relative; margin-bottom: 11px; color: #6887a3; font: 700 9px/1 ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; } +.flow-nodes { position: relative; display: flex; gap: 10px; } +.flow-nodes span { padding: 9px 13px; color: #aac3d8; border: 1px solid #294b69; border-radius: 6px; background: #102d4b; font: 10px/1 ui-monospace, monospace; } +.flow-line { position: relative; display: flex; width: 250px; height: 41px; justify-content: space-around; } +.flow-line i { width: 1px; height: 100%; background: linear-gradient(#356688, #48b9f4); } +.arena-card { position: relative; width: min(410px, 100%); padding: 20px; border: 1px solid #2f8bc0; border-radius: 12px; background: linear-gradient(120deg, #0c3254, #0d2a47); box-shadow: 0 0 40px rgb(27 143 229 / 12%); } +.arena-card > div:first-child { display: grid; grid-template-columns: 20px 1fr; } +.arena-card > div > span { width: 11px; height: 11px; margin-top: 3px; border: 2px solid #62c9ff; border-radius: 3px; } +.arena-card strong { color: #d9f0ff; font: 600 13px/1.2 ui-monospace, monospace; } +.arena-card small { grid-column: 2; margin-top: 3px; color: #6389a6; font: 9px/1 ui-monospace, monospace; } +.arena-rows { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; margin-top: 18px; } +.arena-rows i { height: 30px; border: 1px solid #285f84; border-radius: 3px; background: repeating-linear-gradient(90deg, rgb(69 178 235 / 18%) 0 5px, transparent 5px 8px); } +.flow-line-bottom { width: 1px; height: 34px; } +.core-card { position: relative; display: flex; width: 260px; min-height: 66px; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #365b7a; border-radius: 9px; background: #102b46; } +.core-card span { color: #6c8ca8; font: 8px/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; } +.core-card strong { margin-top: 7px; color: #c7deef; font-size: 12px; } +.gpu-line { position: relative; width: 1px; height: 28px; background: #315a77; } +.gpu-card { position: relative; padding: 8px 21px; color: #83d5ff; border: 1px solid #2c6f98; border-radius: 20px; background: #0c304f; font: 700 9px/1 ui-monospace, monospace; letter-spacing: .1em; } + +.steps-section { padding-top: 115px; padding-bottom: 120px; } +.section-heading-centered { margin-inline: auto; text-align: center; } +.section-heading-centered .eyebrow { justify-content: center; } +.steps { max-width: 880px; margin: 55px auto 0; padding: 0; border-top: 1px solid var(--line); list-style: none; } +.steps li { display: grid; min-height: 126px; grid-template-columns: 46px 1fr auto; align-items: center; gap: 24px; border-bottom: 1px solid var(--line); } +.step-index { display: grid; width: 34px; height: 34px; place-items: center; color: var(--blue); border: 1px solid #b9dcf4; border-radius: 50%; background: var(--sky-50); font: 700 11px/1 ui-monospace, monospace; } +.steps h3 { margin: 0 0 5px; font-size: 16px; } +.steps p { margin: 0; color: #708196; font-size: 13px; } +.steps code { padding: 10px 13px; color: #2977ad; border: 1px solid #d9e7f1; border-radius: 6px; background: #f8fbfd; font: 11px/1 ui-monospace, monospace; } +.cta-section { display: flex; min-height: 305px; align-items: center; justify-content: space-between; gap: 50px; margin-bottom: 90px; padding: 56px 65px; border-radius: 20px; background: radial-gradient(circle at 85% 20%, #229eea, transparent 35%), linear-gradient(125deg, #0a2542, #0c4b78); box-shadow: 0 24px 55px rgb(12 64 101 / 18%); } +.cta-section > div { max-width: 700px; } +.cta-section h2 { margin-block: 13px; font-size: 39px; } +.cta-section p { color: #a7c4d9; } +.button-white { flex: 0 0 auto; color: #155a89; border-color: white; background: white; box-shadow: none; } +.button-white:hover { color: #0f70ae; background: #f0f9ff; box-shadow: none; } +.site-footer { display: grid; min-height: 100px; grid-template-columns: 1fr 1fr 1fr; align-items: center; border-top: 1px solid var(--line); } +.site-footer p { color: #8694a5; font-size: 12px; text-align: center; } +.site-footer nav { display: flex; justify-content: flex-end; gap: 24px; } + +[data-reveal] { opacity: 0; transform: translateY(12px); transition: opacity 500ms ease, transform 500ms ease; } +[data-reveal].revealed { opacity: 1; transform: none; } + +@media (max-width: 980px) { + .hero { grid-template-columns: 1fr; padding-top: 80px; } + .hero-copy { max-width: 700px; } + .hero-demo { width: min(680px, 100%); transform: none; } + .gear-grid { grid-template-columns: 1fr; } + .gear-card { min-height: 320px; } + .architecture-grid { grid-template-columns: 1fr; gap: 60px; } + .architecture-copy { max-width: 700px; } + .trust-strip { grid-template-columns: 1fr 1fr; } + .trust-strip div:nth-child(2) { border-right: 0; } + .trust-strip div:nth-child(-n + 2) { border-bottom: 1px solid var(--line); } +} + +@media (max-width: 700px) { + .section-shell, .site-header { width: min(100% - 28px, 1180px); } + .site-header { height: 64px; gap: 14px; } + .site-nav { justify-content: flex-end; gap: 16px; } + .site-nav a:last-child { display: none; } + .site-header > .button { display: none; } + .hero { min-height: auto; grid-template-columns: minmax(0, 1fr); gap: 45px; padding-block: 60px; } + .hero h1 { font-size: 47px; } + .hero-lede { font-size: 16px; } + .hero-actions { align-items: stretch; flex-direction: column; } + .hero-demo pre { font-size: 10px; } + .trust-strip { grid-template-columns: 1fr; } + .trust-strip div { min-height: 72px; border-right: 0; border-bottom: 1px solid var(--line); } + .progression, .steps-section { padding-block: 80px; } + .architecture-section { padding-block: 80px; } + .architecture-visual { transform: scale(.82); margin-inline: -35px; } + .steps li { grid-template-columns: 42px 1fr; padding-block: 22px; } + .steps code { display: none; } + .cta-section { align-items: flex-start; flex-direction: column; margin-bottom: 45px; padding: 36px 28px; } + .site-footer { grid-template-columns: 1fr; gap: 18px; padding-block: 28px; text-align: center; } + .site-footer p { margin: 0; } + .site-footer nav { justify-content: center; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/server/web/assets/site.js b/server/web/assets/site.js new file mode 100644 index 0000000..ee5f81f --- /dev/null +++ b/server/web/assets/site.js @@ -0,0 +1,18 @@ +const revealTargets = document.querySelectorAll( + ".section-heading, .gear-card, .architecture-copy, .architecture-visual, .steps li, .cta-section", +); + +if ("IntersectionObserver" in window && !matchMedia("(prefers-reduced-motion: reduce)").matches) { + for (const target of revealTargets) target.dataset.reveal = ""; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + entry.target.classList.add("revealed"); + observer.unobserve(entry.target); + } + }, + { rootMargin: "0px 0px -40px", threshold: 0.08 }, + ); + for (const target of revealTargets) observer.observe(target); +} diff --git a/server/web/assets/yawn.d.ts b/server/web/assets/yawn.d.ts new file mode 100644 index 0000000..580aedd --- /dev/null +++ b/server/web/assets/yawn.d.ts @@ -0,0 +1,161 @@ +declare module "@yawn/core" { + export type RowFormat = "f32" | "u32" | "i32"; + export interface RowDescriptor { + name: string; + rows: number; + stride: number; + format: RowFormat; + offset: number; + bytes: number; + } + export class SharedRows { + readonly buffer: SharedArrayBuffer; + readonly descriptor: RowDescriptor; + readonly name: string; + readonly rows: number; + readonly stride: number; + readonly format: RowFormat; + readonly view: Float32Array | Uint32Array | Int32Array; + row(index: number): Float32Array | Uint32Array | Int32Array; + read(index: number): number[]; + write(index: number, values: ArrayLike): this; + share(): { buffer: SharedArrayBuffer; descriptor: RowDescriptor }; + } + export interface ProfileFrame { + frame: number; + milliseconds: number; + readbackMilliseconds: number; + adapter: string; + canvas: { width: number; height: number }; + passes: Array<{ name: string; milliseconds: number }>; + } + export class YawnCore { + constructor(canvas: HTMLCanvasElement, options?: { arenaBytes?: number; debug?: boolean }); + readonly ready: Promise; + createRows(options: { name: string; rows: number; stride: number; format: RowFormat }): Promise; + createRowsBatch(rows: Array<{ name: string; rows: number; stride: number; format: RowFormat }>): Promise; + deleteRows(name: string): Promise; + allocateObject(name: string): Promise; + deleteObject(name: string, id: number): Promise; + compileGraph(serialized: string): Promise; + switchLoadout(id: string): Promise; + play(): Promise; + pause(): Promise; + setFps(fps: number): Promise; + setProfiler(enabled: boolean): Promise; + onProfile(listener: (frame: ProfileFrame) => void): () => void; + array(name: string): SharedRows; + dispose(): void; + } +} + +declare module "@yawn/handles" { + import { SharedRows, YawnCore } from "@yawn/core"; + + export interface SceneOptions { arenaBytes?: number; debug?: boolean; fps?: number; hdr?: boolean } + export class Scene { + constructor(canvas: HTMLCanvasElement, options?: SceneOptions); + readonly core: YawnCore; + readonly ready: Promise; + readonly hdr: boolean; + array(name: string): SharedRows; + ensureRows(name: string, rows: number, stride: number, format: "f32" | "u32" | "i32"): Promise; + reserve(additional: { nodes?: number; materials?: number }): Promise; + batchWrites(operation: () => T): T; + batchGraphUpdates(operation: () => T | Promise): Promise; + dispose(): void; + } + + export interface Vector3View { x: number; y: number; z: number; set(values: ArrayLike): void } + export interface RotorView extends Vector3View { w: number } + export interface NodeOptions { position?: ArrayLike; rotor?: ArrayLike; scale?: ArrayLike } + export class Node { + constructor(scene: Scene, options?: NodeOptions); + readonly scene: Scene; + readonly ready: Promise; + readonly id: number; + readonly position: Vector3View; + readonly rotor: RotorView; + readonly scale: Vector3View; + setPosition(value: ArrayLike): this; + setRotor(value: ArrayLike): this; + setScale(value: ArrayLike): this; + translate(x: number, y: number, z: number): this; + rotateX(radians: number): this; + rotateY(radians: number): this; + rotateZ(radians: number): this; + dispose(): Promise; + } + + export interface PBRMaterialOptions { + baseColor?: ArrayLike; + metallic?: number; + roughness?: number; + emissive?: ArrayLike; + } + export class PBRMaterial { + constructor(scene: Scene, options?: PBRMaterialOptions); + readonly ready: Promise; + readonly id: number; + metallic: number; + roughness: number; + readonly baseColor: { 0: number; 1: number; 2: number; 3: number }; + dispose(): Promise; + } + + export type VertexKind = "positions" | "normals" | "tangents" | "uvs" | "colors" | "indices"; + export interface MeshOptions extends NodeOptions { + geometryId?: number; + material?: PBRMaterial; + vertexData?: Partial>>; + visible?: boolean; + } + export class Mesh extends Node { + constructor(scene: Scene, options?: MeshOptions); + geometryId: number; + vertexCount: number; + indexCount: number; + isVisible: boolean; + material: PBRMaterial; + clone(options?: Omit): Mesh; + setVertexData(kind: VertexKind, data: ArrayLike): Promise; + setMaterialForFaces(material: PBRMaterial, faces: number | number[]): Promise; + } + + export interface CameraOptions extends NodeOptions { fov?: number; near?: number; far?: number; aspect?: number } + export class Camera extends Node { + fov: number; + near: number; + far: number; + aspect: number; + } + export class ArcRotateCamera extends Camera { + constructor(scene: Scene, options?: CameraOptions & { target?: Node; alpha?: number; beta?: number; radius?: number; controls?: { element: HTMLElement; pointer?: boolean; controller?: boolean } }); + alpha: number; + beta: number; + radius: number; + } + export class FreeCamera extends Camera { + constructor(scene: Scene, options?: CameraOptions & { controls?: { element: HTMLElement; keyboard?: boolean; pointer?: boolean; controller?: boolean; speed?: number } }); + } + export class FollowCamera extends Camera { + constructor(scene: Scene, options?: CameraOptions & { target?: Node; distance?: number; height?: number; smoothing?: number }); + } + + export interface LightOptions extends NodeOptions { color?: ArrayLike; intensity?: number } + export class PointLight extends Node { constructor(scene: Scene, options?: LightOptions & { range?: number }); intensity: number; range: number } + export class AmbientLight extends Node { constructor(scene: Scene, options?: LightOptions); intensity: number } + export class DirectionalLight extends Node { constructor(scene: Scene, options?: LightOptions); intensity: number } + export class SpotLight extends Node { constructor(scene: Scene, options?: LightOptions & { range?: number; innerAngle?: number; outerAngle?: number }) } + export class RectAreaLight extends Node { constructor(scene: Scene, options?: LightOptions & { width?: number; height?: number }) } + + export class ComputePass { constructor(options: Record); readonly id: string } + export class FXAA { constructor(scene: Scene, options?: Record); readonly ready: Promise; dispose(): Promise } + export class ColorGrading extends FXAA { constructor(scene: Scene, options?: { toneMap?: "aces" | "reinhard" | "linear"; amount?: number }) } + export class DynamicExposure extends FXAA { constructor(scene: Scene, options?: { exposure?: number }) } + export class SSAO extends FXAA { constructor(scene: Scene, options?: { amount?: number }) } + export function importGltf(scene: Scene, url: string | URL): Promise; +} + +declare const canvas: HTMLCanvasElement; +declare function log(...values: unknown[]): void; diff --git a/server/web/docs.html b/server/web/docs.html new file mode 100644 index 0000000..8eca51b --- /dev/null +++ b/server/web/docs.html @@ -0,0 +1,369 @@ + + + + + + + + Learn Yawn — Documentation + + + + + + + +
+ + +
+
+
+
Start here · 5 minute read
+

Build your first Yawn scene

+

+ This guide starts from an empty HTML file and ends with a WebGPU triangle you can + move, shade, and inspect. You do not need engine experience. Every new term is + explained where it first appears. +

+
+ i +
New to programming?

Type the examples exactly as shown. Words in code style are names the computer expects; the prose around them explains why.

+
+
+ +
+
01 Installation
+

Load Handles from Yawn’s CDN

+

+ You do not build or host Yawn yourself. An import map gives the CDN module a + short name that the rest of this tutorial can use. Put this inside your page’s + <head>: +

+
+
index.html
+
<script type="importmap">
+{
+  "imports": {
+    "@yawn/handles": "https://yawn.heaust.org/pkg/handles.js"
+  }
+}
+</script>
+
+

+ That URL serves a ready-to-use ES module. Its render worker, import worker, picking + worker, and WebAssembly module all continue loading from Yawn’s CDN automatically. +

+

Make a canvas

+

A canvas is the rectangle where WebGPU will draw. Add one to the page body:

+
+
index.html
+
<canvas id="view" width="1280" height="720"></canvas>
+<script type="module" src="/app.js"></script>
+
+
+ ! +
Your page needs isolation headers

Your own web host must send COOP: same-origin and COEP: require-corp so SharedArrayBuffer is available. The CDN already sends the matching CORS and resource-policy headers for Yawn’s files.

+
+
+ +
+
02 Your first scene
+

Scene → material → mesh

+

+ A scene owns the shared data and render graph. A + material describes the surface. A mesh supplies points + and tells Yawn which order to connect them. +

+
+
app.js
+
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
+
+const canvas = document.querySelector("#view");
+const scene = new Scene(canvas, { hdr: true, fps: 60 });
+await scene.ready;
+
+const sky = new PBRMaterial(scene, {
+  baseColor: [0.12, 0.58, 1, 1],
+  metallic: 0.15,
+  roughness: 0.35,
+});
+await sky.ready;
+
+const triangle = new Mesh(scene, {
+  material: sky,
+  vertexData: {
+    positions: [-0.7, -0.6, 0,  0.7, -0.6, 0,  0, 0.72, 0],
+    indices: [0, 1, 2],
+  },
+});
+await triangle.ready;
+
+
+
new

Creates one object. Here that is a scene, material, or mesh handle.

+
await …ready

Waits for setup to finish before the next object depends on it.

+
[x, y, z]

One point in 3D space: horizontal, vertical, and depth.

+
+
Run this exampleIt is already loaded in the playground
Open →
+
+ +
+
03 Move around
+

Add a camera you can orbit

+

+ The starter triangle is already in clip space, so it is visible without a camera. + For a 3D world, add an ArcRotateCamera. Drag to orbit and use the wheel to + zoom. +

+
+
app.ts
+
import { ArcRotateCamera } from "@yawn/handles";
+
+const camera = new ArcRotateCamera(scene, {
+  target: triangle,
+  alpha: 0,
+  beta: Math.PI / 2,
+  radius: 3,
+  controls: { element: canvas, pointer: true },
+});
+await camera.ready;
+
+
+ +
+
04 Build a world
+

Clone geometry, not work

+

+ Every Mesh is an instance. Calling clone() shares its geometry + and creates only a new transform and mesh slot. If one clone later changes its vertex + data, Yawn makes that geometry unique automatically. +

+
+
instances.ts
+
for (let x = -4; x <= 4; x++) {
+  const copy = triangle.clone({ position: [x * 0.35, 0, 0] });
+  await copy.ready;
+}
+
+
+ +
+

Materials and lights are ordinary handles

+

+ Change a material after it is ready and Yawn writes the new value directly into its + shared row. Lights use the same pattern. +

+
+
lighting.ts
+
import { AmbientLight, PointLight } from "@yawn/handles";
+
+const key = new PointLight(scene, {
+  position: [0, 1, 1],
+  color: [1, 0.72, 0.5],
+  intensity: 12,
+  range: 8,
+});
+const fill = new AmbientLight(scene, {
+  color: [0.08, 0.2, 0.5],
+  intensity: 0.3,
+});
+await Promise.all([key.ready, fill.ready]);
+
+sky.roughness = 0.5; // one shared-memory write
+
+
+ +
+

Bring in a glTF model

+

+ importGltf fetches and parses .gltf or .glb data in + a worker, then creates ordinary Yawn meshes and materials. +

+
+
model.ts
+
import { importGltf } from "@yawn/handles";
+
+const meshes = await importGltf(scene, "/models/robot.glb");
+meshes[0].position.y = 0.5;
+
+
+ +
+

Add effects without leaving the scene API

+

Effect handles add passes to the same render graph. Batch related additions to rebuild that graph once.

+
+
effects.ts
+
import { ColorGrading, FXAA } from "@yawn/handles";
+
+await scene.batchGraphUpdates(async () => {
+  const grade = new ColorGrading(scene, { toneMap: "aces" });
+  const fxaa = new FXAA(scene);
+  await Promise.all([grade.ready, fxaa.ready]);
+});
+
+
+ +
+
05 Understand Yawn
+

Setup is messages. Motion is memory.

+

+ Expensive structural changes—creating rows, allocating IDs, or replacing a render + graph—go to the render worker as messages. Values that already exist—positions, + colors, camera matrices, light strengths—change in shared memory. +

+
+
Infrequent controlcreate · allocate · compile · switch
+ +
Shared rowstransform · shade · animate
+ +
Rust/WASM coreschedule · upload · render
+
+
+ +
+

Write your own hot data

+

+ Handles are views over named rows. Your application can add rows too. Each row is + aligned for predictable CPU and GPU use. +

+
+
simulation.ts
+
const velocity = await scene.ensureRows(
+  "app.velocity", 10_000, 16, "f32"
+);
+
+velocity.row(42).set([1, 0, 0, 0]);
+
+

+ That final line changes four floats without cloning an object or posting a worker + message. The scene marks shared data dirty so the next frame sees it. +

+
+ +
+

Measure the passes the GPU actually ran

+

+ Open Profile in the playground to enable timestamp queries. Yawn + reports the physical pass names and GPU milliseconds without serializing the render + queue. Support depends on the browser and adapter. +

+
Profile a real scene

The playground’s profiler uses core.onProfile() and core.setProfiler(true)—the same public APIs available to your app.

+
+ +
+
06 Deep dive
+

Outgrow handles without outgrowing Yawn

+

+ @yawn/core has no scene, mesh, material, camera, or built-in shader. It + owns a shared arena and a render-graph runtime. Handles are one replaceable frontend + that builds on those two primitives. +

+
+
core.ts
+
import { YawnCore } from "https://yawn.heaust.org/pkg/core.js";
+
+const core = new YawnCore(canvas, {
+  arenaBytes: 64 * 1024 * 1024,
+});
+await core.ready;
+
+const particles = await core.createRows({
+  name: "particles",
+  rows: 100_000,
+  stride: 16,
+  format: "f32",
+});
+
+

+ From here, your frontend supplies WGSL, resources, pipelines, and a pass DAG. Core + compiles that description into an up-front loadout, aliases compatible transient + textures, and records render work. +

+
+ +
+

Craft an API for your problem

+

+ A custom handle can be as small as an ID plus getters and setters into shared rows. + Keep domain policy in your code and send only structural changes to core. +

+
+
Particle.ts
+
class Particle {
+  constructor(
+    readonly id: number,
+    readonly positions: SharedRows,
+  ) {}
+
+  set x(value: number) {
+    this.positions.row(this.id)[0] = value;
+  }
+}
+
+
+ +
+

Deployment checklist

+
    +
  • 1
    Serve over HTTPS

    WebGPU and cross-origin isolation require a secure browser context outside local development.

  • +
  • 2
    Keep the isolation headers

    Send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.

  • +
  • 3
    Allow Yawn’s CDN

    The imported module keeps every worker and WASM request on yawn.heaust.org. If you use a Content Security Policy, allow that origin and blob: workers.

  • +
+
Next step

Make the starter scene yours.

Open the playground, choose a snippet, and save a revision you can share.

Open playground →
+
+
+
+ + +
+ + diff --git a/server/web/home.html b/server/web/home.html new file mode 100644 index 0000000..bb7abc7 --- /dev/null +++ b/server/web/home.html @@ -0,0 +1,205 @@ + + + + + + + + Yawn — a WebGPU engine that grows with you + + + + + + +
+
+
+
WebGPU without the ceremony
+

Start with a scene.
Scale into an engine.

+

+ Yawn gives you approachable TypeScript handles on day one, direct shared-memory + performance when you need it, and a programmable render core when your ideas outgrow + conventions. +

+ +
+ + One CDN import · No framework required · Rust/WASM core +
+
+ +
+
+ app.ts + 60 FPS +
+
import { Scene, Mesh, PBRMaterial }
+  from "@yawn/handles";
+
+const scene = new Scene(canvas);
+await scene.ready;
+
+const blue = new PBRMaterial(scene, {
+  baseColor: [0.12, 0.58, 1, 1],
+  roughness: 0.35,
+});
+
+const triangle = new Mesh(scene, {
+  material: blue,
+  vertexData: { positions, indices },
+});
+ +
+
+ +
+
Oneshared arena
+
Zeromessages for hot state
+
Anyrender graph you can describe
+
SmallRust/WASM foundation
+
+ +
+
+
Progressive by design
+

Use exactly as much engine as you need.

+

+ You do not have to choose between an easy API and a serious foundation. Yawn keeps + those layers separate, so learning more never means starting over. +

+
+
+ +
+
02
+
+

Move through shared data

+

+ Positions, cameras, lights, materials, and your own application rows live in one + SharedArrayBuffer. Hot updates become direct typed-array writes. +

+ Understand the fast path +
+
+
03
+
+

Author the whole graph

+

+ Use core directly when you need custom resources, WGSL pipelines, compute, pass + dependencies, transient aliasing, and up-front GPU loadouts. +

+ Go beneath handles +
+
+
+ +
+
+
+
A quiet hot path
+

Your frame should move data, not negotiate it.

+

+ Yawn pays setup costs when your scene or graph changes. During play, ordinary updates + stay in shared rows that JavaScript and the render worker can both see. +

+
    +
  • Structure-of-arrays layout with aligned rows
  • +
  • Render graphs compiled before they become active
  • +
  • Optional timestamp profiling for physical GPU passes
  • +
+ See how the pieces connect → +
+
+
Your application
+
+ handlesworkersgame logic +
+
+
+
SharedArrayBufferaligned SOA arena
+
+
+
+
Rust / WASMRender graph core
+
+
WebGPU
+
+
+
+ +
+
+
From blank page to pixels
+

A small API with visible boundaries.

+
+
    +
  1. + 1 +

    Create a scene

    Give Yawn a canvas and await one clear readiness promise.

    + new Scene(canvas) +
  2. +
  3. + 2 +

    Add what you can see

    Materials and meshes own typed handles, not hidden global state.

    + new Mesh(scene, options) +
  4. +
  5. + 3 +

    Change values directly

    After setup, ordinary property updates write into shared rows.

    + mesh.position.x += 0.1 +
  6. +
+
+ +
+
+
The best way to understand it
+

Change the code. Watch the passes.

+

+ The playground includes TypeScript intelligence, reusable snippets, live output, + shareable SQLite-backed revisions, and the engine’s real GPU profiler. +

+
+ Launch playground +
+
+ + + + diff --git a/server/web/playground.html b/server/web/playground.html new file mode 100644 index 0000000..1571cb8 --- /dev/null +++ b/server/web/playground.html @@ -0,0 +1,138 @@ + + + + + + + + Yawn Playground + + + + + +
+
+ + Yawn + +
+
+ + + Draft +
+ + + + +
+ +
+
+ + + +
+
+ + TypeScript + + Starting language service… +
+
+ + +
+
+ +
+
+
+ +
+
Loading TypeScript editor…
+
+ Ln 1, Col 1 + Spaces: 2 + UTF-8 + + No problems +
+
+ + + +
+
+
+ +
+
+ Ready + + 0 FPS + + +
+
+
+ +
+ Y + Preparing your scene + The first run compiles the render graph. +
+
+
+
+ + + +
+
+
›_ Output from log() appears here.
+
    +
    +
    +
    + +
    Waiting for GPU timestampsRun the scene to inspect its physical passes.
    +
    + +
    +
    +
    +
    +
    + + +
    +
    Code library

    Snippets

    Insert a focused building block at your cursor.

    + +
    + +
    + + + + + +
    +
    + +
    + +