diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6559e49..2c1280e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -42,13 +42,19 @@ jobs: - name: Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 + + - name: Install + working-directory: app + run: npm install + + - name: Test (sim wire + drift guard) + working-directory: app + run: npm test - name: Build site working-directory: app - run: | - npm install - npm run build + run: npm run build - uses: actions/upload-pages-artifact@v3 with: diff --git a/app/package-lock.json b/app/package-lock.json index 512735d..8b43e9e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -21,6 +21,8 @@ "@lezer/highlight": "^1.2.3" }, "devDependencies": { + "@types/node": "^20.19.43", + "tsx": "^4.23.13", "typescript": "^5.6.0", "vite": "^5.4.0" } @@ -428,6 +430,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "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", @@ -445,6 +464,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "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", @@ -462,6 +498,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "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", @@ -1009,6 +1062,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/crelt": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", @@ -1186,6 +1249,458 @@ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1200,6 +1715,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/app/package.json b/app/package.json index ed9d94c..d941bd2 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,8 @@ "wasm": "wasm-pack build ../wasm --release --target web --out-dir ../app/src/wasm --out-name comline_playground_wasm", "dev": "npm run wasm && vite", "build": "tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "tsc -p tsconfig.test.json && node --import tsx --test \"src/sim/**/*.test.ts\"" }, "dependencies": { "@codemirror/autocomplete": "^6.18.0", @@ -23,6 +24,8 @@ "@lezer/highlight": "^1.2.3" }, "devDependencies": { + "@types/node": "^20.19.43", + "tsx": "^4.23.13", "typescript": "^5.6.0", "vite": "^5.4.0" } diff --git a/app/src/sim/generic.ts b/app/src/sim/generic.ts new file mode 100644 index 0000000..02e683a --- /dev/null +++ b/app/src/sim/generic.ts @@ -0,0 +1,110 @@ +/// Route B: one `Client` / `Dispatch` pair that reads a `ProtocolShape` and +/// does what a generated `Client` / `Dispatcher` does — so any +/// compiled schema can be wired up without code generation. The drift guard in +/// `wire.test.ts` proves the frames match the generator's. + +import type { Client, Codec, Dispatch, Kind, Reply } from "./runtime/index.ts"; +import { resolveKind, RuntimeError } from "./runtime/index.ts"; +import type { FnShape, ProtocolShape } from "./shape.ts"; + +/// A schema error the peer raised. `errorName` is `undefined` when the ordinal +/// isn't in the calling function's `throws` (a `RuntimeError.remote` on the +/// generated side). +export class SimRemoteError extends Error { + constructor( + readonly ordinal: number, + readonly errorName: string | undefined, + readonly data: unknown, + ) { + super(errorName ? `${errorName} (ordinal ${ordinal})` : `remote error, ordinal ${ordinal}`); + this.name = "SimRemoteError"; + } +} + +export type SimOutcome = + | { kind: "ok"; value?: unknown } + | { kind: "err"; ordinal: number; data?: unknown } + | { kind: "none" }; + +export interface BehaviorCtx { + /** The decoded request params. */ + params: unknown; + fn: FnShape; + proto: ProtocolShape; +} + +/** What a server instance does for one function when dispatched. */ +export interface Behavior { + run(ctx: BehaviorCtx): Promise | SimOutcome; +} + +/** One behavior per function name. */ +export type BehaviorMap = Record; + +/// The consumer side. `call(fnName, params)` frames the call, waits for the +/// response, and either returns the decoded value or throws `SimRemoteError`. +export class GenericClient { + constructor( + private readonly client: Client, + private readonly proto: ProtocolShape, + ) {} + + async call(fnName: string, params: unknown): Promise { + const fn = this.proto.functions.find((f) => f.name === fnName); + if (!fn) throw new Error(`${this.proto.name} has no function \`${fnName}\``); + const address: Kind & { name: string } = { id: fn.index, name: fn.name }; + + if (fn.oneway) { + await this.client.notify(address, params); + return undefined; + } + + const env = await this.client.call(address, params); + if ("ok" in env) return this.client.codec.decode(env.ok); + const thrown = fn.throws.find((t) => t.ordinal === env.err.id); + throw new SimRemoteError( + env.err.id, + thrown?.name, + env.err.body.length ? this.client.codec.decode(env.err.body) : null, + ); + } +} + +/// The provider side. Resolves the call to a `FnShape`, decodes params, runs +/// the instance's `Behavior` for that function, and records the outcome on the +/// `Reply` — exactly the three shapes a generated dispatcher writes. +export class GenericDispatch implements Dispatch { + constructor( + private readonly proto: ProtocolShape, + private readonly behaviors: BehaviorMap, + ) {} + + calls(): readonly string[] { + return this.proto.functions.map((f) => f.name); + } + + async dispatch(call: Kind, params: Uint8Array, codec: Codec, reply: Reply): Promise { + const idx = resolveKind(call, this.calls()); + if (idx === undefined) throw RuntimeError.unknownCall(); + const fn = this.proto.functions[idx]; + const behavior = this.behaviors[fn.name]; + if (!behavior) throw new Error(`no behavior set for \`${fn.name}\``); + + const outcome = await behavior.run({ + params: params.length ? codec.decode(params) : null, + fn, + proto: this.proto, + }); + + switch (outcome.kind) { + case "ok": + reply.ok(codec.encode(outcome.value ?? null)); + return; + case "err": + reply.err(outcome.ordinal, codec.encode(outcome.data ?? null)); + return; + case "none": + return; // one-way — nothing to send + } + } +} diff --git a/app/src/sim/runtime/VENDORED.md b/app/src/sim/runtime/VENDORED.md new file mode 100644 index 0000000..c332637 --- /dev/null +++ b/app/src/sim/runtime/VENDORED.md @@ -0,0 +1,20 @@ +# Vendored `@comline/runtime` + +`*.ts` here (and `framing/`) are a verbatim copy of +`ComlineProject/comline-typescript` `runtime/src/` — the package is not +published (`version 0.0.0`), so it is pinned by copy the way `wasm/Cargo.toml` +pins `comline-core` by rev. + +- Source: +- Commit: `5c20074113a748cd96b639c38c5c030c2de9fbea` (`runtime/src/`) + +`_fixture_chat.ts` is `runtime/test/generated/chat.ts` from the same commit, +its `@comline/runtime` import repointed at `./index.ts`. It is the drift guard: +`sim/wire.test.ts` runs a call through it and through `GenericDispatch` and +asserts identical wire frames. + +## Re-vendoring + +Re-copy `runtime/src/*` and `runtime/test/generated/chat.ts`, repoint the +fixture import, bump the commit above. Do it when the runtime contract moves +(new framing, transport, envelope shape). diff --git a/app/src/sim/runtime/_fixture_chat.ts b/app/src/sim/runtime/_fixture_chat.ts new file mode 100644 index 0000000..f45c270 --- /dev/null +++ b/app/src/sim/runtime/_fixture_chat.ts @@ -0,0 +1,140 @@ +// Generated by Comline + +import { + Client, + Server, + Handshake, + RuntimeError, + resolveKind, + DatagramFraming, + type Codec, + type Dispatch, + type Framing, + type Kind, + type Reply, + type Transport, +} from "./index.ts"; + +/** Canonical digest of the frozen IR this file was generated from — the +* two ends of a connection must agree on it. */ +export const IR_HASH = 0x6b347d4fa800b29fn; + +export interface Message { + body: string; + seq: number; +} + +export interface Rejected { + reason: string; +} + +export class RejectedError extends Error { + static readonly ordinal = 0; + constructor(readonly data: Rejected) { + super("Rejected"); + this.name = "RejectedError"; + } +} + +export interface ChatSendParams { + text: string; +} + +export interface ChatHistoryParams { + limit: number; +} + +export interface ChatNoteParams { + text: string; +} + +export interface Chat { + /** @throws {RejectedError} */ + send(params: ChatSendParams): Promise; + history(params: ChatHistoryParams): Promise; + wipe(): Promise; + note(params: ChatNoteParams): Promise; +} + +export const CHAT_CALLS = ["send", "history", "wipe", "note"] as const; + +export class ChatDispatcher implements Dispatch { + constructor(private readonly impl: Chat) {} + + calls(): readonly string[] { + return CHAT_CALLS; + } + + async dispatch(call: Kind, params: Uint8Array, codec: Codec, reply: Reply): Promise { + switch (resolveKind(call, CHAT_CALLS)) { + case 0: { + const p = codec.decode(params); + try { + reply.ok(codec.encode((await this.impl.send(p)) ?? null)); + } catch (e) { + if (e instanceof RejectedError) { reply.err(RejectedError.ordinal, codec.encode(e.data)); return; } + throw e; + } + return; + } + case 1: { + const p = codec.decode(params); + reply.ok(codec.encode((await this.impl.history(p)) ?? null)); + return; + } + case 2: { + reply.ok(codec.encode((await this.impl.wipe()) ?? null)); + return; + } + case 3: { + const p = codec.decode(params); + await this.impl.note(p); + return; + } + default: + throw RuntimeError.unknownCall(); + } + } +} + +export class ChatClient { + constructor(private readonly client: Client) {} + + static async connect(transport: Transport, codec: Codec, framing: Framing = new DatagramFraming()): Promise { + const hs = new Handshake({ irHash: IR_HASH, wireFormat: codec.name, framing: framing.name }); + return new ChatClient(await Client.connect(transport, codec, hs, framing)); + } + + async send(params: ChatSendParams): Promise { + const env = await this.client.call({ id: 0, name: "send" }, params); + if ("ok" in env) return this.client.codec.decode(env.ok); + switch (env.err.id) { + case RejectedError.ordinal: + throw new RejectedError(this.client.codec.decode(env.err.body)); + default: + throw RuntimeError.remote(env.err.id); + } + } + + async history(params: ChatHistoryParams): Promise { + const env = await this.client.call({ id: 1, name: "history" }, params); + if ("ok" in env) return this.client.codec.decode(env.ok); + throw RuntimeError.remote(env.err.id); + } + + async wipe(): Promise { + const env = await this.client.call({ id: 2, name: "wipe" }, null); + if ("ok" in env) return; + throw RuntimeError.remote(env.err.id); + } + + async note(params: ChatNoteParams): Promise { + await this.client.notify({ id: 3, name: "note" }, params); + } +} + +export function serveChat(impl: Chat, transport: Transport, codec: Codec, framing: Framing = new DatagramFraming()): Promise { + const hs = new Handshake({ irHash: IR_HASH, wireFormat: codec.name, framing: framing.name }); + return new Server(new ChatDispatcher(impl), codec, framing).serveHandshaked(transport, hs); +} + diff --git a/app/src/sim/runtime/client.ts b/app/src/sim/runtime/client.ts new file mode 100644 index 0000000..0298417 --- /dev/null +++ b/app/src/sim/runtime/client.ts @@ -0,0 +1,61 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import type { Call, Codec, Envelope, Framing } from "./contract.js"; +import { RuntimeError } from "./contract.js"; +import { Handshake } from "./handshake.js"; +import type { Transport } from "./transport.js"; +import { DatagramFraming } from "./framing/datagram.js"; + +/** + * The consumer side. Frames a call, sends it, waits for the matching response, + * and hands the generated stub the raw {@link Envelope} to decode. Generic over + * the {@link Framing}; defaults to {@link DatagramFraming}. + */ +export class Client { + private nextId = 0n; + + constructor( + private readonly transport: Transport, + readonly codec: Codec, + readonly framing: Framing = new DatagramFraming(), + ) {} + + /** + * Bind and run the connection {@link Handshake}: send `local`, read the + * peer's, refuse (`RuntimeError("handshake")`) on a mismatch. + */ + static async connect( + transport: Transport, + codec: Codec, + local: Handshake, + framing: Framing = new DatagramFraming(), + ): Promise { + await transport.send(local.encode()); + const peer = Handshake.decode(await transport.recv()); + if (!peer) throw RuntimeError.handshake(); + local.check(peer); + return new Client(transport, codec, framing); + } + + /** Make `call` with `params`, block for the response, return its {@link Envelope}. */ + async call(call: Call, params: unknown): Promise { + const requestId = this.nextId++; + await this.transport.send( + this.framing.encodeRequest(call, requestId, this.codec.encode(params)), + ); + const res = this.framing.decodeResponse(await this.transport.recv()); + if (!res) throw RuntimeError.framing(); + if (res.requestId !== requestId) throw RuntimeError.framing(); + return res.envelope; + } + + /** Fire-and-forget: send the call, expect no response (a one-way function). */ + async notify(call: Call, params: unknown): Promise { + const requestId = this.nextId++; + await this.transport.send( + this.framing.encodeRequest(call, requestId, this.codec.encode(params)), + ); + } +} diff --git a/app/src/sim/runtime/codec.ts b/app/src/sim/runtime/codec.ts new file mode 100644 index 0000000..14b8227 --- /dev/null +++ b/app/src/sim/runtime/codec.ts @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +/** + * Wire formats. {@link JsonCodec} pairs with the Rust runtime's `format::Json` + * (`name === "json"`); a MessagePack codec matching `format::MsgPack` is a + * later addition. + */ + +import type { Codec } from "./contract.js"; + +/** + * UTF-8 JSON. `bigint` fields (only 128-bit IDL integers map to `bigint`) are + * written as JSON numbers and read back as `number` — lossless below 2^53. A + * precise `bigint` round-trip waits on the tagged binary codec. + */ +export class JsonCodec implements Codec { + readonly name = "json"; + + encode(value: unknown): Uint8Array { + const json = JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? Number(v) : v, + ); + if (json === undefined) { + throw new TypeError("JsonCodec: value is not JSON-serializable"); + } + return new TextEncoder().encode(json); + } + + decode(bytes: Uint8Array): T { + return JSON.parse(new TextDecoder().decode(bytes)) as T; + } +} diff --git a/app/src/sim/runtime/contract.ts b/app/src/sim/runtime/contract.ts new file mode 100644 index 0000000..1c93447 --- /dev/null +++ b/app/src/sim/runtime/contract.ts @@ -0,0 +1,177 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +/** + * The framing-agnostic RPC contract — the TypeScript mirror of + * `comline-runtime`'s `contract` module. Generated bindings and (later) a + * `Client` / `Server` are written against these types. + */ + +/** Every way a call can fail below the application layer. */ +export type RuntimeErrorKind = + | "transport" // the transport failed to send or receive + | "serialization" // a payload would not encode / decode against its type + | "framing" // the call frame was malformed + | "timeout" // no response within the call's window + | "unknownCall" // the peer addressed a call this side does not have + | "remote" // the peer raised a schema error this side's code does not know + | "handshake"; // the connection handshake disagreed + +export class RuntimeError extends Error { + override readonly name = "RuntimeError"; + readonly kind: RuntimeErrorKind; + /** Schema-global error ordinal, set only when `kind === "remote"`. */ + readonly remoteId?: number; + + constructor(kind: RuntimeErrorKind, remoteId?: number) { + super(remoteId === undefined ? kind : `${kind} (#${remoteId})`); + this.kind = kind; + this.remoteId = remoteId; + } + + static transport(): RuntimeError { + return new RuntimeError("transport"); + } + static serialization(): RuntimeError { + return new RuntimeError("serialization"); + } + static framing(): RuntimeError { + return new RuntimeError("framing"); + } + static timeout(): RuntimeError { + return new RuntimeError("timeout"); + } + static unknownCall(): RuntimeError { + return new RuntimeError("unknownCall"); + } + static remote(id: number): RuntimeError { + return new RuntimeError("remote", id); + } + static handshake(): RuntimeError { + return new RuntimeError("handshake"); + } + + is(kind: RuntimeErrorKind): boolean { + return this.kind === kind; + } +} + +/** + * How a call is addressed on the wire. A datagram framing carries the ordinal + * (`id`); a name-oriented framing (JSON-RPC) carries the method `name`. A + * generated stub supplies both and lets the framing pick. + */ +export type Kind = { id: number } | { name: string }; + +/** Resolve a {@link Kind} to a call ordinal against the protocol's call list. */ +export function resolveKind(kind: Kind, calls: readonly string[]): number | undefined { + if ("id" in kind) { + return kind.id < calls.length ? kind.id : undefined; + } + const i = calls.indexOf(kind.name); + return i === -1 ? undefined : i; +} + +/** A `Call` a generated stub passes — both addresses, the framing picks one. */ +export interface Call { + readonly id: number; + readonly name: string; +} + +export function call(id: number, name: string): Call { + return { id, name }; +} + +/** + * A decoded response body: an `ok` payload for the stub to decode as its return + * type, or an `err` payload keyed by the schema-global error ordinal for the + * generated error table to map. + */ +export type Envelope = + | { readonly ok: Uint8Array } + | { readonly err: { readonly id: number; readonly body: Uint8Array } }; + +/** What a dispatched handler recorded — nothing (one-way), an ok, or an error. */ +export type Outcome = + | { readonly kind: "none" } + | { readonly kind: "ok" } + | { readonly kind: "err"; readonly id: number }; + +/** + * The framing-agnostic sink a generated dispatcher writes its result into. The + * `Server` (a later PR) turns the {@link Outcome} + body into whatever envelope + * the active framing wants. + */ +export class Reply { + outcome: Outcome = { kind: "none" }; + body: Uint8Array = new Uint8Array(0); + + ok(body: Uint8Array): void { + this.body = body; + this.outcome = { kind: "ok" }; + } + + err(id: number, body: Uint8Array): void { + this.body = body; + this.outcome = { kind: "err", id }; + } +} + +/** A call outcome for a generated client method: an app error, or a runtime one. */ +export type CallError = { readonly app: E } | { readonly runtime: RuntimeError }; + +/** + * A serialization format — the TypeScript `WireFormat`. `name` is folded into + * the {@link Handshake} so the two ends can catch "one side JSON, one side + * MessagePack" before exchanging real frames. + */ +export interface Codec { + readonly name: string; + encode(value: unknown): Uint8Array; + decode(bytes: Uint8Array): T; +} + +/** + * A generated dispatcher implements this: the ordered call list, and a + * `dispatch` that decodes params, runs the handler, and records the result on + * the {@link Reply}. + */ +export interface Dispatch { + calls(): readonly string[]; + dispatch(call: Kind, params: Uint8Array, codec: Codec, reply: Reply): Promise; +} + +/** Whichever call address a framing put on the wire. */ +export type RequestCall = { readonly id: number } | { readonly name: string }; + +/** A decoded request frame. */ +export interface DecodedRequest { + readonly call: RequestCall; + readonly requestId: bigint; + /** The params sub-frame, independently decodable with the peer's {@link Codec}. */ + readonly params: Uint8Array; +} + +/** A decoded response frame: the correlation id and its {@link Envelope}. */ +export interface DecodedResponse { + readonly requestId: bigint; + readonly envelope: Envelope; +} + +/** + * How a call becomes bytes and back — the axis orthogonal to {@link Codec} + * (which serializes the *parts*). {@link DatagramFraming} is the default; + * {@link JsonRpcFraming} is the name-oriented alternative. `params` / `payload` + * / `body` arrive already {@link Codec}-encoded; the framing only positions + * them. Both ends of a connection must agree — the {@link Handshake} carries + * `name`, hashed. + */ +export interface Framing { + readonly name: string; + encodeRequest(call: Call, requestId: bigint, params: Uint8Array): Uint8Array; + decodeRequest(frame: Uint8Array): DecodedRequest | undefined; + encodeResponseOk(requestId: bigint, payload: Uint8Array): Uint8Array; + encodeResponseErr(requestId: bigint, id: number, body: Uint8Array): Uint8Array; + decodeResponse(frame: Uint8Array): DecodedResponse | undefined; +} diff --git a/app/src/sim/runtime/envelope.ts b/app/src/sim/runtime/envelope.ts new file mode 100644 index 0000000..517dd4a --- /dev/null +++ b/app/src/sim/runtime/envelope.ts @@ -0,0 +1,43 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +/** + * The tag-byte {@link Envelope} form the Comline datagram framing wraps a + * response body in — `[0] payload` for ok, `[1] id:u16 LE body` for err. + * Matches `comline_runtime::contract::Envelope`. + */ + +import type { Envelope } from "./contract.js"; + +const TAG_OK = 0; +const TAG_ERR = 1; + +export function encodeEnvelopeOk(payload: Uint8Array): Uint8Array { + const out = new Uint8Array(1 + payload.length); + out[0] = TAG_OK; + out.set(payload, 1); + return out; +} + +export function encodeEnvelopeErr(id: number, body: Uint8Array): Uint8Array { + const out = new Uint8Array(3 + body.length); + out[0] = TAG_ERR; + out[1] = id & 0xff; + out[2] = (id >> 8) & 0xff; + out.set(body, 3); + return out; +} + +export function decodeEnvelope(frame: Uint8Array): Envelope | undefined { + const tag = frame[0]; + if (tag === TAG_OK) { + return { ok: frame.subarray(1) }; + } + if (tag === TAG_ERR) { + if (frame.length < 3) return undefined; + const id = frame[1]! | (frame[2]! << 8); + return { err: { id, body: frame.subarray(3) } }; + } + return undefined; +} diff --git a/app/src/sim/runtime/framing/datagram.ts b/app/src/sim/runtime/framing/datagram.ts new file mode 100644 index 0000000..126d14d --- /dev/null +++ b/app/src/sim/runtime/framing/datagram.ts @@ -0,0 +1,60 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import type { Call, DecodedRequest, DecodedResponse, Framing } from "../contract.js"; +import { FRAMING_DATAGRAM } from "../handshake.js"; +import { decodeEnvelope, encodeEnvelopeErr, encodeEnvelopeOk } from "../envelope.js"; + +const HEAD = 10; // [call_id:u16][request_id:u64], both LE + +/** + * The Comline datagram framing — compact, one frame per message. Matches + * `comline_runtime::contract::DatagramFraming`: + * request `[call_id:u16 LE][request_id:u64 LE][params]`, + * response `[request_id:u64 LE][envelope]`. + */ +export class DatagramFraming implements Framing { + readonly name = FRAMING_DATAGRAM; + + encodeRequest(call: Call, requestId: bigint, params: Uint8Array): Uint8Array { + const out = new Uint8Array(HEAD + params.length); + const view = new DataView(out.buffer); + view.setUint16(0, call.id, true); + view.setBigUint64(2, requestId, true); + out.set(params, HEAD); + return out; + } + + decodeRequest(frame: Uint8Array): DecodedRequest | undefined { + if (frame.length < HEAD) return undefined; + const view = new DataView(frame.buffer, frame.byteOffset, HEAD); + return { + call: { id: view.getUint16(0, true) }, + requestId: view.getBigUint64(2, true), + params: frame.subarray(HEAD), + }; + } + + encodeResponseOk(requestId: bigint, payload: Uint8Array): Uint8Array { + return withRequestId(requestId, encodeEnvelopeOk(payload)); + } + + encodeResponseErr(requestId: bigint, id: number, body: Uint8Array): Uint8Array { + return withRequestId(requestId, encodeEnvelopeErr(id, body)); + } + + decodeResponse(frame: Uint8Array): DecodedResponse | undefined { + if (frame.length < 8) return undefined; + const requestId = new DataView(frame.buffer, frame.byteOffset, 8).getBigUint64(0, true); + const envelope = decodeEnvelope(frame.subarray(8)); + return envelope && { requestId, envelope }; + } +} + +function withRequestId(requestId: bigint, rest: Uint8Array): Uint8Array { + const out = new Uint8Array(8 + rest.length); + new DataView(out.buffer).setBigUint64(0, requestId, true); + out.set(rest, 8); + return out; +} diff --git a/app/src/sim/runtime/framing/jsonrpc.ts b/app/src/sim/runtime/framing/jsonrpc.ts new file mode 100644 index 0000000..0f719f3 --- /dev/null +++ b/app/src/sim/runtime/framing/jsonrpc.ts @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import type { Call, DecodedRequest, DecodedResponse, Framing } from "../contract.js"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +/** JSON bytes of a value, or `null` for an empty sub-frame. */ +function jsonBytes(sub: unknown): Uint8Array { + return enc.encode(sub === undefined ? "null" : JSON.stringify(sub)); +} + +/** + * [JSON-RPC 2.0](https://www.jsonrpc.org/specification) framing — name-oriented, + * human-readable. Pair with {@link JsonCodec}. Matches + * `comline_runtime::framing::JsonRpcFraming`: + * + * - request: `{"jsonrpc":"2.0","method":,"params":,"id":}` + * - ok: `{"jsonrpc":"2.0","result":,"id":}` + * - err: `{"jsonrpc":"2.0","error":{"code":,"message":...,"data":},"id":}` + * + * `params` / `payload` / `body` arrive as already-encoded JSON bytes and are + * spliced in verbatim. + */ +export class JsonRpcFraming implements Framing { + readonly name = "jsonrpc-2.0"; + + encodeRequest(call: Call, requestId: bigint, params: Uint8Array): Uint8Array { + const p = params.length === 0 ? "null" : dec.decode(params); + return enc.encode( + `{"jsonrpc":"2.0","method":${JSON.stringify(call.name)},"params":${p},"id":${requestId}}`, + ); + } + + decodeRequest(frame: Uint8Array): DecodedRequest | undefined { + let r: { method?: unknown; params?: unknown; id?: unknown }; + try { + r = JSON.parse(dec.decode(frame)); + } catch { + return undefined; + } + if (typeof r.method !== "string") return undefined; + return { + call: { name: r.method }, + requestId: r.id === undefined || r.id === null ? 0n : BigInt(r.id as number), + params: jsonBytes(r.params), + }; + } + + encodeResponseOk(requestId: bigint, payload: Uint8Array): Uint8Array { + const r = payload.length === 0 ? "null" : dec.decode(payload); + return enc.encode(`{"jsonrpc":"2.0","result":${r},"id":${requestId}}`); + } + + encodeResponseErr(requestId: bigint, id: number, body: Uint8Array): Uint8Array { + const d = body.length === 0 ? "null" : dec.decode(body); + return enc.encode( + `{"jsonrpc":"2.0","error":{"code":${id},"message":"application error","data":${d}},"id":${requestId}}`, + ); + } + + decodeResponse(frame: Uint8Array): DecodedResponse | undefined { + let r: { result?: unknown; error?: { code?: unknown; data?: unknown }; id?: unknown }; + try { + r = JSON.parse(dec.decode(frame)); + } catch { + return undefined; + } + const requestId = r.id === undefined || r.id === null ? 0n : BigInt(r.id as number); + if (r.error && typeof r.error === "object") { + return { + requestId, + envelope: { err: { id: Number(r.error.code ?? 0), body: jsonBytes(r.error.data) } }, + }; + } + return { requestId, envelope: { ok: jsonBytes(r.result) } }; + } +} diff --git a/app/src/sim/runtime/handshake.ts b/app/src/sim/runtime/handshake.ts new file mode 100644 index 0000000..ed3a2e8 --- /dev/null +++ b/app/src/sim/runtime/handshake.ts @@ -0,0 +1,105 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +/** + * The connection handshake — byte-compatible with `comline-runtime`'s + * `contract::handshake`. Each end sends one fixed 31-byte frame first and + * refuses on a schema / wire-format / framing mismatch. + */ + +import { RuntimeError } from "./contract.js"; + +const MAGIC = Uint8Array.of(0x43, 0x4f); // "CO" +const VERSION = 1; +/** `[MAGIC:2][VERSION:1][ir_hash:u64][wire_format:u64][framing:u64][caps:u32]`, LE. */ +const LEN = 2 + 1 + 8 + 8 + 8 + 4; + +const U64_MASK = (1n << 64n) - 1n; +const FNV_OFFSET = 0xcbf29ce484222325n; +const FNV_PRIME = 0x00000100000001b3n; + +/** Name of Comline's default datagram framing. Pass it to {@link Handshake}. */ +export const FRAMING_DATAGRAM = "comline.datagram"; + +/** + * 64-bit FNV-1a over a name's UTF-8 bytes. Folds a wire-format / framing name + * into the fixed-size handshake without a registry; matches + * `comline_runtime::contract::name_hash`. + */ +export function nameHash(name: string): bigint { + let h = FNV_OFFSET; + for (const b of new TextEncoder().encode(name)) { + h = (h ^ BigInt(b)) & U64_MASK; + h = (h * FNV_PRIME) & U64_MASK; + } + return h; +} + +export interface HandshakeInit { + /** Fingerprint of the frozen IR both ends generated from (the `IR_HASH` const). */ + irHash: bigint; + /** The wire format's name, e.g. `"json"` — hashed in. */ + wireFormat: string; + /** The framing's name, e.g. {@link FRAMING_DATAGRAM} — hashed in. */ + framing: string; + /** Transport capability bits. Advisory: a difference here is not a mismatch. */ + capabilities?: number; +} + +/** What each end declares when a connection opens. */ +export class Handshake { + readonly irHash: bigint; + readonly wireFormat: bigint; + readonly framing: bigint; + readonly capabilities: number; + + constructor(init: HandshakeInit) { + this.irHash = init.irHash & U64_MASK; + this.wireFormat = nameHash(init.wireFormat); + this.framing = nameHash(init.framing); + this.capabilities = (init.capabilities ?? 0) >>> 0; + } + + /** The fixed 31-byte frame. */ + encode(): Uint8Array { + const frame = new Uint8Array(LEN); + const view = new DataView(frame.buffer); + frame.set(MAGIC, 0); + frame[2] = VERSION; + view.setBigUint64(3, this.irHash, true); + view.setBigUint64(11, this.wireFormat, true); + view.setBigUint64(19, this.framing, true); + view.setUint32(27, this.capabilities, true); + return frame; + } + + /** Parse a frame; `undefined` if truncated or the magic / version is wrong. */ + static decode(frame: Uint8Array): Handshake | undefined { + if (frame.length < LEN) return undefined; + if (frame[0] !== MAGIC[0] || frame[1] !== MAGIC[1] || frame[2] !== VERSION) { + return undefined; + } + const view = new DataView(frame.buffer, frame.byteOffset, LEN); + const h = Object.create(Handshake.prototype) as Handshake; + return Object.assign(h, { + irHash: view.getBigUint64(3, true), + wireFormat: view.getBigUint64(11, true), + framing: view.getBigUint64(19, true), + capabilities: view.getUint32(27, true), + }); + } + + /** + * Check a peer's declaration against this one. Throws + * `RuntimeError("handshake")` if `irHash`, `wireFormat`, or `framing` + * disagree; capability bits are allowed to differ. + */ + check(peer: Handshake): void { + const agree = + this.irHash === peer.irHash && + this.wireFormat === peer.wireFormat && + this.framing === peer.framing; + if (!agree) throw RuntimeError.handshake(); + } +} diff --git a/app/src/sim/runtime/index.ts b/app/src/sim/runtime/index.ts new file mode 100644 index 0000000..04adc51 --- /dev/null +++ b/app/src/sim/runtime/index.ts @@ -0,0 +1,49 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +/** + * `@comline/runtime` — the TypeScript runtime that Comline-generated RPC + * bindings link against. The framing-agnostic contract, two framings, an + * in-memory transport, and a `Client` / `Server`. The generator emitting a + * `Client` / dispatcher against this package, and a stream transport, + * follow. + */ + +export { + RuntimeError, + type RuntimeErrorKind, + type Kind, + resolveKind, + type Call, + call, + type Envelope, + type Outcome, + Reply, + type CallError, + type Codec, + type Dispatch, + type RequestCall, + type DecodedRequest, + type DecodedResponse, + type Framing, +} from "./contract.js"; + +export { + FRAMING_DATAGRAM, + nameHash, + Handshake, + type HandshakeInit, +} from "./handshake.js"; + +export { JsonCodec } from "./codec.js"; + +export { encodeEnvelopeOk, encodeEnvelopeErr, decodeEnvelope } from "./envelope.js"; + +export { DatagramFraming } from "./framing/datagram.js"; +export { JsonRpcFraming } from "./framing/jsonrpc.js"; + +export { type Transport, type InMemoryTransport, duplex } from "./transport.js"; + +export { Client } from "./client.js"; +export { Server } from "./server.js"; diff --git a/app/src/sim/runtime/server.ts b/app/src/sim/runtime/server.ts new file mode 100644 index 0000000..a1e4cea --- /dev/null +++ b/app/src/sim/runtime/server.ts @@ -0,0 +1,73 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import type { Codec, Dispatch, Framing } from "./contract.js"; +import { Reply, RuntimeError, resolveKind } from "./contract.js"; +import { Handshake } from "./handshake.js"; +import type { Transport } from "./transport.js"; +import { DatagramFraming } from "./framing/datagram.js"; + +/** + * The provider side. Reads a request frame, dispatches it, writes the response + * frame — until the transport closes. Generic over the {@link Framing}; + * defaults to {@link DatagramFraming}. + */ +export class Server { + constructor( + private readonly dispatch: Dispatch, + private readonly codec: Codec, + private readonly framing: Framing = new DatagramFraming(), + ) {} + + /** Handle one call. `true` — served; `false` — the transport closed. */ + async serveOne(transport: Transport): Promise { + let frame: Uint8Array; + try { + frame = await transport.recv(); + } catch { + return false; + } + + const req = this.framing.decodeRequest(frame); + if (!req) throw RuntimeError.framing(); + + const idx = resolveKind(req.call, this.dispatch.calls()); + if (idx === undefined) throw RuntimeError.unknownCall(); + + const reply = new Reply(); + await this.dispatch.dispatch({ id: idx }, req.params, this.codec, reply); + + switch (reply.outcome.kind) { + case "none": + return true; // one-way call: nothing to reply + case "ok": + await transport.send(this.framing.encodeResponseOk(req.requestId, reply.body)); + return true; + case "err": + await transport.send( + this.framing.encodeResponseErr(req.requestId, reply.outcome.id, reply.body), + ); + return true; + } + } + + /** Serve calls until the transport closes. No handshake. */ + async serve(transport: Transport): Promise { + while (await this.serveOne(transport)) { + /* keep serving */ + } + } + + /** + * Run the connection {@link Handshake} against the connecting peer — send + * `local`, read theirs, refuse on a mismatch — then {@link Server.serve}. + */ + async serveHandshaked(transport: Transport, local: Handshake): Promise { + await transport.send(local.encode()); + const peer = Handshake.decode(await transport.recv()); + if (!peer) throw RuntimeError.handshake(); + local.check(peer); + await this.serve(transport); + } +} diff --git a/app/src/sim/runtime/transport.ts b/app/src/sim/runtime/transport.ts new file mode 100644 index 0000000..787e5b5 --- /dev/null +++ b/app/src/sim/runtime/transport.ts @@ -0,0 +1,81 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import { RuntimeError } from "./contract.js"; + +/** + * A message-oriented byte pipe: each {@link Transport.send} delivers exactly + * one frame to the peer's next {@link Transport.recv}. The counterpart of the + * Rust `Transport` trait; a stream transport (length-prefixed) is a later add. + */ +export interface Transport { + send(frame: Uint8Array): Promise; + /** Resolves with the next frame; rejects `RuntimeError("transport")` once the peer is gone and the queue is drained. */ + recv(): Promise; +} + +interface Waiter { + resolve(v: Uint8Array): void; + reject(e: unknown): void; +} + +/** One direction of an in-memory pipe: a queue plus parked receivers. */ +class Channel { + private readonly queue: Uint8Array[] = []; + private readonly waiters: Waiter[] = []; + private closed = false; + + push(frame: Uint8Array): void { + const waiter = this.waiters.shift(); + if (waiter) waiter.resolve(frame); + else this.queue.push(frame); + } + + pull(): Promise { + const next = this.queue.shift(); + if (next !== undefined) return Promise.resolve(next); + if (this.closed) return Promise.reject(RuntimeError.transport()); + return new Promise((resolve, reject) => this.waiters.push({ resolve, reject })); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const w of this.waiters.splice(0)) w.reject(RuntimeError.transport()); + } +} + +class InMemoryTransport implements Transport { + constructor( + private readonly inbox: Channel, + private readonly outbox: Channel, + ) {} + + send(frame: Uint8Array): Promise { + this.outbox.push(frame.slice()); // copy: the caller may reuse its buffer + return Promise.resolve(); + } + + recv(): Promise { + return this.inbox.pull(); + } + + /** Drop this end — the peer's pending / next `recv` rejects, ending a serve loop. */ + close(): void { + this.outbox.close(); + } +} + +/** + * A connected in-memory transport pair — the TypeScript `duplex()`. What one + * end sends, the other receives. `close()` on either end makes the peer's + * pending / next `recv` reject. + */ +export function duplex(): [InMemoryTransport, InMemoryTransport] { + const a = new Channel(); + const b = new Channel(); + return [new InMemoryTransport(a, b), new InMemoryTransport(b, a)]; +} + +export type { InMemoryTransport }; diff --git a/app/src/sim/transport.ts b/app/src/sim/transport.ts new file mode 100644 index 0000000..5527265 --- /dev/null +++ b/app/src/sim/transport.ts @@ -0,0 +1,124 @@ +/// The wire between two instances: a `duplex()` pair from the vendored runtime, +/// each end wrapped so every frame it carries is recorded and (optionally) +/// delayed. The recording is what the frame inspector reads. + +import { duplex, type Transport } from "./runtime/transport.ts"; + +export type FrameKind = "handshake" | "request" | "response"; + +export interface Frame { + /** Monotonic, assigned when the frame is sent. */ + seq: number; + from: string; + to: string; + /** A copy — the caller may reuse its buffer. */ + bytes: Uint8Array; + /** `performance.now()` at send. */ + at: number; + /** Best-effort classification from the byte shape (see `classify`). */ + kind: FrameKind; +} + +const HANDSHAKE_LEN = 31; // [MAGIC:2][VERSION:1][ir_hash:8][wire:8][framing:8][caps:4] + +/// Best-effort label for the log. The handshake frame is unambiguous (fixed +/// length + `CO` magic); a JSON-RPC frame's `method` key marks it a request. +/// A binary datagram request and response can't be told apart from the bytes +/// alone, so those default to `request` — the engine overrides `kind` when it +/// knows the direction (1e). +function classify(bytes: Uint8Array): FrameKind { + if (bytes.length === HANDSHAKE_LEN && bytes[0] === 0x43 && bytes[1] === 0x4f) { + return "handshake"; + } + if (bytes[0] === 0x7b) { + return new TextDecoder().decode(bytes).includes('"method"') ? "request" : "response"; + } + return "request"; +} + +/** A shared sink for the frames on one connection. */ +export class Tap { + private counter = 0; + readonly frames: Frame[] = []; + private readonly listeners = new Set<(f: Frame) => void>(); + + /** Subscribe; returns an unsubscribe. */ + on(fn: (f: Frame) => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + record(from: string, to: string, bytes: Uint8Array): Frame { + const frame: Frame = { + seq: ++this.counter, + from, + to, + bytes: bytes.slice(), + at: performance.now(), + kind: classify(bytes), + }; + this.frames.push(frame); + for (const fn of this.listeners) fn(frame); + return frame; + } + + clear(): void { + this.frames.length = 0; + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** One `duplex()` end plus the tap. `send` records then (optionally) waits + * `latencyMs` before delivering; `recv` passes straight through. */ +export class TappedTransport implements Transport { + constructor( + private readonly inner: Transport & { close?(): void }, + private readonly tap: Tap, + private readonly self: string, + private readonly peer: string, + private readonly latencyMs = 0, + ) {} + + async send(frame: Uint8Array): Promise { + this.tap.record(this.self, this.peer, frame); + if (this.latencyMs > 0) await sleep(this.latencyMs); + return this.inner.send(frame); + } + + recv(): Promise { + return this.inner.recv(); + } + + /** Drop this end — the peer's pending / next `recv` rejects, ending a serve loop. */ + close(): void { + this.inner.close?.(); + } +} + +export interface Wire { + tap: Tap; + /** The transport for the endpoint named `a` (talks to `b`). */ + a: TappedTransport; + /** The transport for the endpoint named `b` (talks to `a`). */ + b: TappedTransport; + /** Drop both ends. */ + close(): void; +} + +/** A tapped connection between two named endpoints. */ +export function wire(a: string, b: string, latencyMs = 0): Wire { + const tap = new Tap(); + const [ta, tb] = duplex(); + const at = new TappedTransport(ta, tap, a, b, latencyMs); + const bt = new TappedTransport(tb, tap, b, a, latencyMs); + return { + tap, + a: at, + b: bt, + close() { + at.close(); + bt.close(); + }, + }; +} diff --git a/app/src/sim/wire.test.ts b/app/src/sim/wire.test.ts new file mode 100644 index 0000000..6ed3712 --- /dev/null +++ b/app/src/sim/wire.test.ts @@ -0,0 +1,208 @@ +/// Headless (`node --test`) proof that route B is a working, faithful wire: +/// - a call and a raised error round-trip over a real `duplex()` +/// - every frame reaches the tap +/// - the frames `GenericDispatch` produces are byte-identical to the ones the +/// committed generated `chat.ts` fixture produces (the drift guard) + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import initWasm, { describe_project } from "../wasm/comline_playground_wasm.js"; +import type { ProjectShape, ProtocolShape } from "./shape.ts"; +import { + Client, + DatagramFraming, + Handshake, + JsonCodec, + JsonRpcFraming, + Server, + type Codec, + type Framing, +} from "./runtime/index.ts"; +import { GenericClient, GenericDispatch, SimRemoteError, type BehaviorMap } from "./generic.ts"; +import { wire } from "./transport.ts"; +import { ChatClient, serveChat } from "./runtime/_fixture_chat.ts"; + +await initWasm( + readFileSync(fileURLToPath(new URL("../wasm/comline_playground_wasm_bg.wasm", import.meta.url))), +); + +const CHAT_IDS = `struct Message { + body: string + seq: u64 +} + +error Rejected { + message = "rejected: {self.reason}" + reason: string +} + +protocol Chat { + function send(text: string) -> Message ! Rejected; + function history(limit: u64) -> Message[]; + function wipe(); + function note(text: string); +} +`; + +function chatShape(framing?: '"jsonrpc"' | '"datagram"'): { + proto: ProtocolShape; + irHash: string; +} { + const src = framing ? CHAT_IDS.replace("protocol Chat", `@framing = ${framing}\nprotocol Chat`) : CHAT_IDS; + const shape = describe_project([{ path: "chat.ids", source: src }]) as ProjectShape; + const schema = shape.schemas.find((s) => s.namespace === "chat")!; + return { proto: schema.protocols[0], irHash: schema.ir_hash }; +} + +/** Stand up a generic client ⇄ server pair on a tapped wire. */ +async function connect( + proto: ProtocolShape, + irHash: string, + behaviors: BehaviorMap, + codec: Codec, + framing: () => Framing, +) { + const w = wire("client", "server"); + const hs = () => + new Handshake({ irHash: BigInt(irHash), wireFormat: codec.name, framing: framing().name }); + const server = new Server(new GenericDispatch(proto, behaviors), codec, framing()); + void server.serveHandshaked(w.b, hs()); + const client = new GenericClient(await Client.connect(w.a, codec, hs(), framing()), proto); + return { w, client }; +} + +const MESSAGE = { body: "HELLO", seq: 7 }; + +test("send round-trips a decoded reply (datagram)", async () => { + const { proto, irHash } = chatShape(); + const { w, client } = await connect( + proto, + irHash, + { send: { run: () => ({ kind: "ok", value: MESSAGE }) } }, + new JsonCodec(), + () => new DatagramFraming(), + ); + w.tap.clear(); + + const reply = await client.call("send", { text: "hi" }); + assert.deepEqual(reply, MESSAGE); + assert.equal(w.tap.frames.length, 2, "one request frame, one response frame"); + assert.deepEqual( + w.tap.frames.map((f) => [f.from, f.to]), + [ + ["client", "server"], + ["server", "client"], + ], + ); + w.close(); +}); + +test("send round-trips over JSON-RPC framing", async () => { + const { proto, irHash } = chatShape('"jsonrpc"'); + assert.equal(proto.framing, "jsonrpc"); + const { w, client } = await connect( + proto, + irHash, + { send: { run: () => ({ kind: "ok", value: MESSAGE }) } }, + new JsonCodec(), + () => new JsonRpcFraming(), + ); + w.tap.clear(); + + assert.deepEqual(await client.call("send", { text: "hi" }), MESSAGE); + assert.ok( + w.tap.frames.every((f) => f.bytes[0] === 0x7b /* '{' */), + "JSON-RPC frames are text", + ); + w.close(); +}); + +test("a raised error comes back as SimRemoteError with the ordinal's name", async () => { + const { proto, irHash } = chatShape(); + const { w, client } = await connect( + proto, + irHash, + { send: { run: () => ({ kind: "err", ordinal: 0, data: { reason: "nope" } }) } }, + new JsonCodec(), + () => new DatagramFraming(), + ); + + await assert.rejects( + () => client.call("send", { text: "hi" }), + (e: unknown) => { + assert.ok(e instanceof SimRemoteError); + assert.equal(e.ordinal, 0); + assert.equal(e.errorName, "Rejected"); + assert.deepEqual(e.data, { reason: "nope" }); + return true; + }, + ); + w.close(); +}); + +test("a one-way call sends a request and expects no response", async () => { + const { proto, irHash } = chatShape(); + let got: unknown; + const { w, client } = await connect( + proto, + irHash, + { note: { run: (cx) => ((got = cx.params), { kind: "none" }) } }, + new JsonCodec(), + () => new DatagramFraming(), + ); + w.tap.clear(); + + assert.equal(await client.call("note", { text: "fyi" }), undefined); + await new Promise((r) => setTimeout(r, 0)); // let the server run + assert.deepEqual(got, { text: "fyi" }); + assert.equal(w.tap.frames.length, 1, "request only, no reply"); + w.close(); +}); + +test("drift guard — GenericDispatch frames match the generated ChatDispatcher", async () => { + const { proto, irHash } = chatShape(); + const codec = new JsonCodec(); + const stub = () => ({ kind: "ok" as const, value: MESSAGE }); + + // route B + const g = await connect( + proto, + irHash, + { + send: { run: stub }, + history: { run: () => ({ kind: "ok", value: [] }) }, + wipe: { run: () => ({ kind: "none" }) }, + note: { run: () => ({ kind: "none" }) }, + }, + codec, + () => new DatagramFraming(), + ); + g.w.tap.clear(); + await g.client.call("send", { text: "hi" }); + const routeB = g.w.tap.frames.map((f) => Buffer.from(f.bytes).toString("hex")); + g.w.close(); + + // the committed generated fixture + const f = wire("client", "server"); + void serveChat( + { + send: async () => MESSAGE, + history: async () => [], + wipe: async () => {}, + note: async () => {}, + }, + f.b, + codec, + new DatagramFraming(), + ); + const fixtureClient = await ChatClient.connect(f.a, codec, new DatagramFraming()); + f.tap.clear(); + await fixtureClient.send({ text: "hi" }); + const generated = f.tap.frames.map((fr) => Buffer.from(fr.bytes).toString("hex")); + f.close(); + + assert.deepEqual(routeB, generated); +}); diff --git a/app/tsconfig.json b/app/tsconfig.json index 4183611..49bea6e 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -10,5 +10,6 @@ "skipLibCheck": true, "types": ["vite/client"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/app/tsconfig.test.json b/app/tsconfig.test.json new file mode 100644 index 0000000..1fc472e --- /dev/null +++ b/app/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"], + "noEmit": true + }, + "include": ["src/sim"], + "exclude": [] +}