From ba3f6c99f08893c611bb49c566f65146e6dfbfef Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Mon, 12 Oct 2026 14:56:37 +0000 Subject: [PATCH] refactor(web): extract shared UI and host packages --- web/BUILD.bazel | 7 + web/platform/BUILD.bazel | 30 +++++ .../view => platform}/auto-refresh.test.tsx | 0 .../view => platform}/auto-refresh.tsx | 0 web/platform/eslint.config.mjs | 1 + web/platform/index.ts | 5 + .../view => platform}/navigation.tsx | 0 web/platform/package.json | 45 +++++++ .../core => platform}/path-segment.ts | 0 web/platform/styles.css | 120 +++++++++++++++++ web/platform/test-setup.ts | 1 + .../view => platform}/timestamp.tsx | 4 +- web/platform/tsconfig.build.json | 20 +++ web/platform/tsconfig.json | 20 +++ web/platform/vitest.config.ts | 12 ++ web/pnpm-lock.yaml | 95 ++++++++++++++ web/pnpm-workspace.yaml | 1 + web/service/shared/BUILD.bazel | 27 ++++ web/service/shared/auth.test.ts | 42 ++++++ web/service/shared/auth.ts | 66 ++++++++++ web/service/shared/eslint.config.mjs | 1 + .../src/next => shared}/health.test.ts | 0 .../src/next => shared}/health.ts | 0 .../src/next => shared}/navigation.tsx | 2 +- web/service/shared/package.json | 54 ++++++++ web/service/shared/test/server-only.ts | 1 + .../src/server => shared}/transport.test.ts | 8 +- .../src/server => shared}/transport.ts | 18 +-- web/service/shared/tsconfig.build.json | 21 +++ web/service/shared/tsconfig.json | 20 +++ web/service/shared/vitest.config.ts | 11 ++ web/service/submitqueue/BUILD.bazel | 13 ++ web/service/submitqueue/next.config.ts | 2 +- web/service/submitqueue/package.json | 3 +- .../submitqueue/src/app/healthz/route.ts | 2 +- web/service/submitqueue/src/next/page.tsx | 2 +- .../submitqueue/src/server/auth.test.ts | 39 +----- web/service/submitqueue/src/server/auth.ts | 63 +-------- web/service/submitqueue/src/server/config.ts | 4 +- web/service/submitqueue/src/server/gateway.ts | 2 +- web/service/submitqueue/vitest.config.ts | 2 +- web/submitqueue/BUILD.bazel | 117 +++-------------- web/submitqueue/core/encoding.test.ts | 2 +- web/submitqueue/core/paths.test.ts | 3 +- web/submitqueue/core/paths.ts | 4 +- web/submitqueue/core/route.ts | 2 +- web/submitqueue/eslint.config.mjs | 1 + web/submitqueue/index.ts | 2 +- web/submitqueue/package.json | 5 +- web/submitqueue/view/components.test.tsx | 2 +- web/submitqueue/view/components.tsx | 2 +- web/submitqueue/view/pages.test.tsx | 2 +- web/submitqueue/view/pages.tsx | 2 +- web/submitqueue/view/refresh-control.tsx | 2 +- web/submitqueue/view/styles.css | 121 +----------------- web/submitqueue/view/web-app.test.tsx | 2 +- web/submitqueue/view/web-app.tsx | 2 +- web/test/package/BUILD.bazel | 7 + web/test/package/consumer-runtime.mjs | 4 +- web/test/package/pack_contents_test.py | 10 +- web/test/package/react18/BUILD.bazel | 2 + web/tool/BUILD.bazel | 10 ++ web/tool/eslint.config.mjs | 27 ++++ web/tool/library.bzl | 81 ++++++++++++ 64 files changed, 820 insertions(+), 356 deletions(-) create mode 100644 web/platform/BUILD.bazel rename web/{submitqueue/view => platform}/auto-refresh.test.tsx (100%) rename web/{submitqueue/view => platform}/auto-refresh.tsx (100%) create mode 100644 web/platform/eslint.config.mjs create mode 100644 web/platform/index.ts rename web/{submitqueue/view => platform}/navigation.tsx (100%) create mode 100644 web/platform/package.json rename web/{submitqueue/core => platform}/path-segment.ts (100%) create mode 100644 web/platform/styles.css create mode 100644 web/platform/test-setup.ts rename web/{submitqueue/view => platform}/timestamp.tsx (72%) create mode 100644 web/platform/tsconfig.build.json create mode 100644 web/platform/tsconfig.json create mode 100644 web/platform/vitest.config.ts create mode 100644 web/service/shared/BUILD.bazel create mode 100644 web/service/shared/auth.test.ts create mode 100644 web/service/shared/auth.ts create mode 100644 web/service/shared/eslint.config.mjs rename web/service/{submitqueue/src/next => shared}/health.test.ts (100%) rename web/service/{submitqueue/src/next => shared}/health.ts (100%) rename web/service/{submitqueue/src/next => shared}/navigation.tsx (97%) create mode 100644 web/service/shared/package.json create mode 100644 web/service/shared/test/server-only.ts rename web/service/{submitqueue/src/server => shared}/transport.test.ts (74%) rename web/service/{submitqueue/src/server => shared}/transport.ts (69%) create mode 100644 web/service/shared/tsconfig.build.json create mode 100644 web/service/shared/tsconfig.json create mode 100644 web/service/shared/vitest.config.ts create mode 100644 web/submitqueue/eslint.config.mjs create mode 100644 web/tool/eslint.config.mjs create mode 100644 web/tool/library.bzl diff --git a/web/BUILD.bazel b/web/BUILD.bazel index 3f32bb61b..e9903358a 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -20,6 +20,13 @@ test_suite( "//web/service/submitqueue:typecheck_typecheck_test", "//web/submitqueue:test", "//web/submitqueue:typecheck_typecheck_test", + "//web/platform:lint", + "//web/platform:test", + "//web/platform:typecheck_typecheck_test", + "//web/service/shared:lint", + "//web/service/shared:test", + "//web/service/shared:typecheck_typecheck_test", + "//web/submitqueue:lint", ":e2e_typecheck_typecheck_test", ], ) diff --git a/web/platform/BUILD.bazel b/web/platform/BUILD.bazel new file mode 100644 index 000000000..4958fc6c3 --- /dev/null +++ b/web/platform/BUILD.bazel @@ -0,0 +1,30 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/platform:vitest/package_json.bzl", vitest = "bin") +load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin") +load("//web/tool:library.bzl", "web_library") + +package(default_visibility = ["//web:__subpackages__"]) + +npm_link_all_packages(name = "node_modules") + +web_library( + assets = ["styles.css"], + eslint = eslint, + package = "@submitqueue/web-platform", + test_deps = [ + ":node_modules/@testing-library/jest-dom", + ":node_modules/@testing-library/react", + ":node_modules/@types/react-dom", + ":node_modules/jsdom", + ":node_modules/react-dom", + ":node_modules/vitest", + ], + vitest = vitest, + deps = [ + ":node_modules/@types/node", + ":node_modules/@types/react", + ":node_modules/react", + ], +) + +exports_files(["styles.css"]) diff --git a/web/submitqueue/view/auto-refresh.test.tsx b/web/platform/auto-refresh.test.tsx similarity index 100% rename from web/submitqueue/view/auto-refresh.test.tsx rename to web/platform/auto-refresh.test.tsx diff --git a/web/submitqueue/view/auto-refresh.tsx b/web/platform/auto-refresh.tsx similarity index 100% rename from web/submitqueue/view/auto-refresh.tsx rename to web/platform/auto-refresh.tsx diff --git a/web/platform/eslint.config.mjs b/web/platform/eslint.config.mjs new file mode 100644 index 000000000..6459dc7c8 --- /dev/null +++ b/web/platform/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from "../tool/eslint.config.mjs"; diff --git a/web/platform/index.ts b/web/platform/index.ts new file mode 100644 index 000000000..d70eb1dd8 --- /dev/null +++ b/web/platform/index.ts @@ -0,0 +1,5 @@ +"use client"; + +export * from "./navigation.js"; +export * from "./auto-refresh.js"; +export * from "./timestamp.js"; diff --git a/web/submitqueue/view/navigation.tsx b/web/platform/navigation.tsx similarity index 100% rename from web/submitqueue/view/navigation.tsx rename to web/platform/navigation.tsx diff --git a/web/platform/package.json b/web/platform/package.json new file mode 100644 index 000000000..c8f2fa30b --- /dev/null +++ b/web/platform/package.json @@ -0,0 +1,45 @@ +{ + "name": "@submitqueue/web-platform", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": [ + "./styles.css" + ], + "files": [ + "dist", + "styles.css" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./path-segment": { + "types": "./dist/path-segment.d.ts", + "import": "./dist/path-segment.js" + }, + "./styles.css": "./styles.css" + }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json", + "lint": "eslint .", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/node": "22.18.6", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "jsdom": "^26.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "typescript": "^5.8.3", + "vitest": "4.0.7" + }, + "peerDependencies": { + "react": ">=18 <20" + } +} diff --git a/web/submitqueue/core/path-segment.ts b/web/platform/path-segment.ts similarity index 100% rename from web/submitqueue/core/path-segment.ts rename to web/platform/path-segment.ts diff --git a/web/platform/styles.css b/web/platform/styles.css new file mode 100644 index 000000000..f335aff3a --- /dev/null +++ b/web/platform/styles.css @@ -0,0 +1,120 @@ +/* Common native UI styles, scoped to .sq-app; hosts may override --sq-* properties. */ +.sq-app { + color-scheme: light; + --sq-surface: #fff; + --sq-surface-muted: #f5f6f8; + --sq-text: #202327; + --sq-muted: #626a74; + --sq-border: #e3e6e9; + --sq-accent: #175fc5; + --sq-accent-soft: #edf3ff; + --sq-success: #187247; + --sq-success-soft: #edf8f1; + --sq-warning: #8b590f; + --sq-warning-soft: #fff5e7; + --sq-danger: #b33535; + --sq-danger-soft: #fff0ef; + --sq-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --sq-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--sq-font); +} +.sq-app[data-theme="dark"] { + color-scheme: dark; + --sq-surface: #16181b; + --sq-surface-muted: #202328; + --sq-text: #e8eaed; + --sq-muted: #a0a7b0; + --sq-border: #33373d; + --sq-accent: #6ea8ff; + --sq-accent-soft: #1c2a40; + --sq-success: #5cc28a; + --sq-success-soft: #18301f; + --sq-warning: #e0a84a; + --sq-warning-soft: #36290f; + --sq-danger: #ff7b72; + --sq-danger-soft: #3a1f1e; +} +@media (prefers-color-scheme: dark) { + .sq-app[data-theme="system"] { + color-scheme: dark; + --sq-surface: #16181b; + --sq-surface-muted: #202328; + --sq-text: #e8eaed; + --sq-muted: #a0a7b0; + --sq-border: #33373d; + --sq-accent: #6ea8ff; + --sq-accent-soft: #1c2a40; + --sq-success: #5cc28a; + --sq-success-soft: #18301f; + --sq-warning: #e0a84a; + --sq-warning-soft: #36290f; + --sq-danger: #ff7b72; + --sq-danger-soft: #3a1f1e; + } +} +.sq-app, .sq-app * { box-sizing: border-box; } +.sq-app { background: var(--sq-surface); color: var(--sq-text); font-size: 14px; line-height: 1.5; } +.sq-app a { color: var(--sq-accent); text-decoration: none; overflow-wrap: anywhere; } +.sq-app a:hover { text-decoration: underline; } +.sq-app a:focus-visible, .sq-app button:focus-visible, .sq-app input:focus-visible, .sq-app select:focus-visible, .sq-app summary:focus-visible { + outline: 3px solid var(--sq-accent); outline-offset: 3px; +} +.sq-app h1 { font-size: 26px; line-height: 1.25; margin: 0 0 10px; letter-spacing: -.5px; } +.sq-app h2 { font-size: 16px; margin: 0 0 12px; } +.sq-app p { margin: 8px 0; } +.sq-app button, .sq-app input, .sq-app select { + font: inherit; border: 1px solid var(--sq-border); border-radius: 6px; + color: var(--sq-text); background: var(--sq-surface); padding: 8px 12px; +} +.sq-app button, .sq-app summary { cursor: pointer; } +.sq-app button:disabled { cursor: default; opacity: .65; } +.sq-app button:hover:not(:disabled) { background: var(--sq-surface-muted); } +.sq-app input { min-width: 0; } +.sq-app.site-frame { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; } +.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: min(1200px, calc(100% - 48px)); margin-inline: auto; } +.sq-app .site-header { min-height: 80px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--sq-border); } +.sq-app .brand { display: flex; align-items: center; gap: 9px; color: var(--sq-text); font-weight: 600; font-size: 18px; } +.sq-app .brand-mark { background: var(--sq-text); color: var(--sq-surface); border-radius: 6px; padding: 5px 7px; font-size: 12px; } +.sq-app .environment-pill, .sq-app .site-footer, .sq-app .sq-secondary { color: var(--sq-muted); font-size: 12px; } +.sq-app .site-footer { padding: 20px 0; border-top: 1px solid var(--sq-border); } +.sq-app .shell { padding-block: 28px 48px; } +.sq-app .centered-state { min-height: 100vh; display: grid; place-items: center; } +.sq-app .page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; } +.sq-app .page-heading p { color: var(--sq-muted); font-size: 12px; } +.sq-app .eyebrow { font-size: 12px; margin: 0 0 10px; color: var(--sq-muted); } +.sq-app .page-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; } +.sq-app .sq-refresh { display: flex; align-items: center; gap: 10px; color: var(--sq-muted); font-size: 12px; } +.sq-app .sq-error { padding: 16px; border-radius: 6px; background: var(--sq-danger-soft); } +.sq-app .sq-error p { overflow-wrap: anywhere; } +.sq-app .button-link { display: inline-block; margin-top: 16px; } +.sq-app .state-card { max-width: 700px; } +@media (max-width: 680px) { +.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: calc(100% - 32px); } +.sq-app .site-header { gap: 12px; flex-wrap: wrap; padding-block: 14px; } +.sq-app button, .sq-app select, .sq-app input { min-height: 44px; } +} + + +.sq-app .sq-table-scroll { overflow-x: auto; } +.sq-app table { width: 100%; border-collapse: collapse; text-align: left; } +.sq-app th { font-weight: 500; color: var(--sq-muted); font-size: 12px; padding: 10px 12px; border-bottom: 1px solid var(--sq-border); } +.sq-app td { padding: 18px 12px; border-bottom: 1px solid var(--sq-border); vertical-align: top; } +.sq-app th:first-child, .sq-app td:first-child { padding-left: 0; } +.sq-app th:last-child, .sq-app td:last-child { padding-right: 0; } +.sq-app td time { font-size: 12px; color: var(--sq-muted); } +.sq-app .sq-table-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 18px; font-size: 12px; color: var(--sq-muted); } +.sq-app .sq-status, .sq-app .sq-event { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 12px; color: var(--sq-accent); background: var(--sq-accent-soft); white-space: nowrap; } +.sq-app .sq-status[data-tone="neutral"] { color: var(--sq-muted); background: var(--sq-surface-muted); } +.sq-app .sq-status[data-tone="success"] { color: var(--sq-success); background: var(--sq-success-soft); } +.sq-app .sq-status[data-tone="danger"] { color: var(--sq-danger); background: var(--sq-danger-soft); } +.sq-app .sq-status[data-tone="warning"] { color: var(--sq-warning); background: var(--sq-warning-soft); } +.sq-app .sq-empty { padding: 35px 0; color: var(--sq-muted); } +.sq-app .sq-breadcrumb { display: flex; gap: 10px; flex-wrap: wrap; color: var(--sq-muted); font-size: 12px; margin-bottom: 24px; } +.sq-app .sq-detail-columns { display: grid; grid-template-columns: 1fr 1.15fr; gap: 38px; } +.sq-app .sq-facts { margin: 0; } +.sq-app .sq-facts > div { padding: 11px 0; border-bottom: 1px solid var(--sq-border); } +.sq-app dt { color: var(--sq-muted); font-size: 12px; margin-bottom: 5px; } +.sq-app dd { margin: 0; overflow-wrap: anywhere; } +@media (max-width: 680px) { +.sq-app .sq-detail-columns { grid-template-columns: 1fr; gap: 20px; } +} diff --git a/web/platform/test-setup.ts b/web/platform/test-setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/web/platform/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/web/submitqueue/view/timestamp.tsx b/web/platform/timestamp.tsx similarity index 72% rename from web/submitqueue/view/timestamp.tsx rename to web/platform/timestamp.tsx index 685cee81f..e26c65d6e 100644 --- a/web/submitqueue/view/timestamp.tsx +++ b/web/platform/timestamp.tsx @@ -4,8 +4,8 @@ const timestampFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "long", timeZone: "UTC", }); -export function Timestamp({ value }: { value: number }) { - const date = new Date(value); +export function Timestamp({ value }: { value: string | number }) { + const date = new Date(Number(value)); return Number.isNaN(date.getTime()) ? : ; } diff --git a/web/platform/tsconfig.build.json b/web/platform/tsconfig.build.json new file mode 100644 index 000000000..bf3015e17 --- /dev/null +++ b/web/platform/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "test-setup.ts", + "vitest.config.ts" + ] +} diff --git a/web/platform/tsconfig.json b/web/platform/tsconfig.json new file mode 100644 index 000000000..b5c8499cb --- /dev/null +++ b/web/platform/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/web/platform/vitest.config.ts b/web/platform/vitest.config.ts new file mode 100644 index 000000000..21c8ec048 --- /dev/null +++ b/web/platform/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { + jsx: "automatic", + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test-setup.ts"], + }, +}); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 761d8a10f..843eecab6 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -63,6 +63,94 @@ importers: specifier: 2.15.0 version: 2.15.0 + platform: + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.10.0(@testing-library/dom@10.4.2) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + '@types/react': + specifier: ^19.1.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.3.0(@types/react@19.3.0) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + react: + specifier: ^19.1.0 + version: 19.3.0 + react-dom: + specifier: ^19.1.0 + version: 19.3.0(react@19.3.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: 4.0.7 + version: 4.0.7(@types/node@22.18.6)(jsdom@26.1.0) + + service/shared: + dependencies: + '@bufbuild/protobuf': + specifier: 2.15.0 + version: 2.15.0 + '@connectrpc/connect': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-node': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0)) + '@opentelemetry/api': + specifier: 1.9.0 + version: 1.9.0 + '@submitqueue/web-platform': + specifier: 0.0.0 + version: link:../../platform + next: + specifier: '>=16.3.0 <17' + version: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(@types/node@22.18.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pino: + specifier: ^10.4.0 + version: 10.4.0 + react: + specifier: ^19.2.0 + version: 19.3.0 + react-dom: + specifier: ^19.2.0 + version: 19.3.0(react@19.3.0) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + devDependencies: + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + '@types/react': + specifier: ^19.1.16 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.9 + version: 19.3.0(@types/react@19.3.0) + eslint: + specifier: ^9.36.0 + version: 9.39.5 + eslint-config-next: + specifier: '>=16.3.0 <17' + version: 16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.7 + version: 4.0.7(@types/node@22.18.6)(jsdom@27.4.0) + service/submitqueue: dependencies: '@bufbuild/protobuf': @@ -86,6 +174,9 @@ importers: '@submitqueue/api': specifier: 0.1.0 version: link:../../api + '@submitqueue/web-service-shared': + specifier: 0.0.0 + version: link:../shared '@submitqueue/web-submitqueue': specifier: 0.0.0 version: link:../../submitqueue @@ -128,6 +219,10 @@ importers: version: 4.0.7(@types/node@22.18.6)(jsdom@27.4.0) submitqueue: + dependencies: + '@submitqueue/web-platform': + specifier: 0.0.0 + version: link:../platform devDependencies: '@connectrpc/connect': specifier: ^2.1.1 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index a7968ee15..848251e06 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - platform - api - service/* - submitqueue diff --git a/web/service/shared/BUILD.bazel b/web/service/shared/BUILD.bazel new file mode 100644 index 000000000..3c802b563 --- /dev/null +++ b/web/service/shared/BUILD.bazel @@ -0,0 +1,27 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/service/shared:vitest/package_json.bzl", vitest = "bin") +load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin") +load("//web/tool:library.bzl", "web_library") + +package(default_visibility = ["//web:__subpackages__"]) + +npm_link_all_packages(name = "node_modules") + +web_library( + assets = [], + eslint = eslint, + package = "@submitqueue/web-service-shared", + test_deps = [":node_modules/vitest"], + vitest = vitest, + deps = [ + ":node_modules/@connectrpc/connect", + ":node_modules/@connectrpc/connect-node", + ":node_modules/@submitqueue/web-platform", + ":node_modules/@types/node", + ":node_modules/@types/react", + ":node_modules/next", + ":node_modules/pino", + ":node_modules/react", + ":node_modules/server-only", + ], +) diff --git a/web/service/shared/auth.test.ts b/web/service/shared/auth.test.ts new file mode 100644 index 000000000..43dd7120c --- /dev/null +++ b/web/service/shared/auth.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { isAuthorized, authenticationChallenge } from "./auth.js"; +const username = "test"; +const token = "test"; +function basic(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} +describe("isAuthorized", () => { + it("accepts the configured credentials", () => { + expect( + isAuthorized(basic("test", "test"), { token, username }), + ).toBe(true); + }); + + it.each([ + ["missing header", null], + ["wrong scheme", `Bearer ${token}`], + ["malformed base64", "Basic !!!"], + ["missing separator", `Basic ${Buffer.from("submitqueue").toString("base64")}`], + ["wrong user", basic("operator", token)], + ["previous demo user", basic("submitqueue", token)], + ["wrong password", basic(username, "b".repeat(32))], + ])("rejects %s", (_name, authorization) => { + expect(isAuthorized(authorization, { token, username })).toBe(false); + }); + + it("allows colons in the password", () => { + const password = `${token}:suffix`; + expect( + isAuthorized(basic(username, password), { token: password, username }), + ).toBe(true); + }); +}); + +it("responds with a noncacheable challenge for the supplied realm", () => { + const response = authenticationChallenge("Demo"); + expect(response.status).toBe(401); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("www-authenticate")).toBe( + 'Basic realm="Demo", charset="UTF-8"', + ); +}); diff --git a/web/service/shared/auth.ts b/web/service/shared/auth.ts new file mode 100644 index 000000000..89adb69f0 --- /dev/null +++ b/web/service/shared/auth.ts @@ -0,0 +1,66 @@ +import "server-only"; + +import { createHash, timingSafeEqual } from "node:crypto"; + +function decodeBasicCredentials( + authorization: string | null, +): Readonly<{ username: string; password: string }> | undefined { + if (authorization === null) { + return undefined; + } + + const [scheme, encoded, ...extra] = authorization.trim().split(/\s+/u); + if (scheme?.toLowerCase() !== "basic" || !encoded || extra.length !== 0) { + return undefined; + } + if ( + !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded) || + encoded.length % 4 === 1 + ) { + return undefined; + } + + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + + const separator = decoded.indexOf(":"); + if (separator < 0) { + return undefined; + } + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; +} + +function digest(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function securelyEqual(actual: string, expected: string): boolean { + return timingSafeEqual(digest(actual), digest(expected)); +} + +export function isAuthorized( + authorization: string | null, + configuration: Readonly<{ token: string; username: string }>, +): boolean { + const credentials = decodeBasicCredentials(authorization); + if (!credentials) { + return false; + } + return ( + securelyEqual(credentials.username, configuration.username) && + securelyEqual(credentials.password, configuration.token) + ); +} + +export function authenticationChallenge(realm: string): Response { + return new Response("Authentication required", { + status: 401, + headers: { + "Cache-Control": "no-store", + "Content-Type": "text/plain; charset=utf-8", + "WWW-Authenticate": `Basic realm="${realm}", charset="UTF-8"`, + }, + }); +} diff --git a/web/service/shared/eslint.config.mjs b/web/service/shared/eslint.config.mjs new file mode 100644 index 000000000..a7cfa9e2b --- /dev/null +++ b/web/service/shared/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from "../../tool/eslint.config.mjs"; diff --git a/web/service/submitqueue/src/next/health.test.ts b/web/service/shared/health.test.ts similarity index 100% rename from web/service/submitqueue/src/next/health.test.ts rename to web/service/shared/health.test.ts diff --git a/web/service/submitqueue/src/next/health.ts b/web/service/shared/health.ts similarity index 100% rename from web/service/submitqueue/src/next/health.ts rename to web/service/shared/health.ts diff --git a/web/service/submitqueue/src/next/navigation.tsx b/web/service/shared/navigation.tsx similarity index 97% rename from web/service/submitqueue/src/next/navigation.tsx rename to web/service/shared/navigation.tsx index 3057f61d7..8d40ae283 100644 --- a/web/service/submitqueue/src/next/navigation.tsx +++ b/web/service/shared/navigation.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useTransition, type ReactNode } from "react"; -import { WebNavigationProvider, type WebLinkProps, type WebNavigation } from "@submitqueue/web-submitqueue"; +import { WebNavigationProvider, type WebLinkProps, type WebNavigation } from "@submitqueue/web-platform"; // Prefetching dynamic pages would turn every visible link into a backend read. function NextWebLink(props: WebLinkProps) { diff --git a/web/service/shared/package.json b/web/service/shared/package.json new file mode 100644 index 000000000..8ce7a5d8e --- /dev/null +++ b/web/service/shared/package.json @@ -0,0 +1,54 @@ +{ + "name": "@submitqueue/web-service-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest run", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@connectrpc/connect": "^2.1.1", + "@connectrpc/connect-node": "^2.1.1", + "next": ">=16.3.0 <17", + "pino": "^10.4.0", + "react": "^19.2.0", + "server-only": "^0.0.1", + "@submitqueue/web-platform": "0.0.0", + "@bufbuild/protobuf": "2.15.0", + "@opentelemetry/api": "1.9.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "22.18.6", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "eslint": "^9.36.0", + "eslint-config-next": ">=16.3.0 <17", + "typescript": "5.9.3", + "vitest": "4.0.7" + }, + "files": [ + "dist" + ], + "exports": { + "./navigation": { + "types": "./dist/navigation.d.ts", + "import": "./dist/navigation.js" + }, + "./auth": { + "types": "./dist/auth.d.ts", + "import": "./dist/auth.js" + }, + "./transport": { + "types": "./dist/transport.d.ts", + "import": "./dist/transport.js" + }, + "./health": { + "types": "./dist/health.d.ts", + "import": "./dist/health.js" + } + } +} diff --git a/web/service/shared/test/server-only.ts b/web/service/shared/test/server-only.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/web/service/shared/test/server-only.ts @@ -0,0 +1 @@ +export {}; diff --git a/web/service/submitqueue/src/server/transport.test.ts b/web/service/shared/transport.test.ts similarity index 74% rename from web/service/submitqueue/src/server/transport.test.ts rename to web/service/shared/transport.test.ts index c094ecb6b..31b837dc3 100644 --- a/web/service/submitqueue/src/server/transport.test.ts +++ b/web/service/shared/transport.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import { parseGatewayBaseUrl } from "./transport"; +import { parseServiceBaseUrl } from "./transport.js"; -describe("parseGatewayBaseUrl", () => { +describe("parseServiceBaseUrl", () => { it.each([ ["an https origin", "https://gateway.example:8443/", false, "https://gateway.example:8443"], ["an allowed plaintext origin", "http://gateway:8081", true, "http://gateway:8081"], ])("accepts %s", (_name, raw, allowPlaintext, origin) => { - expect(parseGatewayBaseUrl(raw, allowPlaintext)).toBe(origin); + expect(parseServiceBaseUrl(raw, allowPlaintext)).toBe(origin); }); it.each([ @@ -17,6 +17,6 @@ describe("parseGatewayBaseUrl", () => { ["plaintext without opting in", "http://gateway:8081"], ["another protocol", "ftp://gateway.example"], ])("rejects %s", (_name, raw) => { - expect(() => parseGatewayBaseUrl(raw)).toThrow(); + expect(() => parseServiceBaseUrl(raw)).toThrow(); }); }); diff --git a/web/service/submitqueue/src/server/transport.ts b/web/service/shared/transport.ts similarity index 69% rename from web/service/submitqueue/src/server/transport.ts rename to web/service/shared/transport.ts index 19689c576..0b9a56f2e 100644 --- a/web/service/submitqueue/src/server/transport.ts +++ b/web/service/shared/transport.ts @@ -4,7 +4,7 @@ import type { Interceptor, Transport } from "@connectrpc/connect"; import { createGrpcTransport } from "@connectrpc/connect-node"; export interface ConnectTransportConfig { - /** Gateway origin, `https://host[:port]`; `http://` only with `allowPlaintext`. */ + /** Service origin, `https://host[:port]`; `http://` only with `allowPlaintext`. */ baseUrl: string; /** Permits plaintext HTTP/2 (h2c), for local stacks only. */ allowPlaintext?: boolean; @@ -14,25 +14,25 @@ export interface ConnectTransportConfig { } /** - * Validates a gateway URL and returns its origin. Throws on invalid + * Validates a service URL and returns its origin. Throws on invalid * configuration, which is a startup failure rather than a per-request outcome. */ -export function parseGatewayBaseUrl(raw: string, allowPlaintext = false): string { +export function parseServiceBaseUrl(raw: string, allowPlaintext = false): string { if (!URL.canParse(raw)) { - throw new Error("The gateway URL must be a valid absolute URL"); + throw new Error("The service URL must be a valid absolute URL"); } const url = new URL(raw); if (url.pathname !== "/" || url.search || url.hash) { - throw new Error("The gateway URL must not contain a path, query, or fragment"); + throw new Error("The service URL must not contain a path, query, or fragment"); } if (url.username || url.password) { - throw new Error("The gateway URL must not contain credentials"); + throw new Error("The service URL must not contain credentials"); } if (url.protocol === "http:" && !allowPlaintext) { - throw new Error("Plaintext gateway connections must be explicitly allowed"); + throw new Error("Plaintext service connections must be explicitly allowed"); } if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new Error("The gateway URL must use https, or explicitly allowed http"); + throw new Error("The service URL must use https, or explicitly allowed http"); } return url.origin; } @@ -40,7 +40,7 @@ export function parseGatewayBaseUrl(raw: string, allowPlaintext = false): string /** A gRPC transport for Node; TLS unless plaintext is explicitly allowed. */ export function newConnectTransport(config: ConnectTransportConfig): Transport { return createGrpcTransport({ - baseUrl: parseGatewayBaseUrl(config.baseUrl, config.allowPlaintext), + baseUrl: parseServiceBaseUrl(config.baseUrl, config.allowPlaintext), interceptors: [...(config.interceptors ?? [])], ...(config.defaultTimeoutMs === undefined ? {} : { defaultTimeoutMs: config.defaultTimeoutMs }), }); diff --git a/web/service/shared/tsconfig.build.json b/web/service/shared/tsconfig.build.json new file mode 100644 index 000000000..f8107af84 --- /dev/null +++ b/web/service/shared/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "test-setup.ts", + "vitest.config.ts", + "test/**" + ] +} diff --git a/web/service/shared/tsconfig.json b/web/service/shared/tsconfig.json new file mode 100644 index 000000000..0a5bb116e --- /dev/null +++ b/web/service/shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/web/service/shared/vitest.config.ts b/web/service/shared/vitest.config.ts new file mode 100644 index 000000000..10f536a1f --- /dev/null +++ b/web/service/shared/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { + "server-only": path.resolve(import.meta.dirname, "test/server-only.ts"), + }, + }, + test: { environment: "node" }, +}); diff --git a/web/service/submitqueue/BUILD.bazel b/web/service/submitqueue/BUILD.bazel index 222e4698e..7ab617a0c 100644 --- a/web/service/submitqueue/BUILD.bazel +++ b/web/service/submitqueue/BUILD.bazel @@ -39,6 +39,7 @@ ts_project( ":node_modules/@opentelemetry/sdk-metrics", ":node_modules/@submitqueue/api", ":node_modules/@submitqueue/web-submitqueue", + ":node_modules/@submitqueue/web-service-shared", ":node_modules/@types/node", ":node_modules/pino", ":node_modules/@types/react", @@ -66,6 +67,9 @@ eslint.eslint_test( "package.json", "tsconfig.json", "vitest.config.ts", + ":node_modules/@submitqueue/web-service-shared", + "//web/service/shared:lib", + "//web/platform:lib", ] + glob([ "src/**/*.ts", "src/**/*.tsx", @@ -91,6 +95,9 @@ vitest.vitest_test( "//web:node_modules/react", "//web:node_modules/react-dom", "vitest.config.ts", + ":node_modules/@submitqueue/web-service-shared", + "//web/service/shared:lib", + "//web/platform:lib", ] + glob([ "src/**/*.ts", "src/**/*.tsx", @@ -115,6 +122,7 @@ nextjs_standalone_build( ":node_modules/@opentelemetry/sdk-metrics", ":node_modules/@submitqueue/api", ":node_modules/@submitqueue/web-submitqueue", + ":node_modules/@submitqueue/web-service-shared", ":node_modules/next", ":node_modules/react", ":node_modules/react-dom", @@ -128,6 +136,11 @@ nextjs_standalone_build( "//web:node_modules/react-dom", "//web/submitqueue:lib", "//web/submitqueue:lib_types", + "//web/platform:lib", + "//web/platform:lib_types", + "//web/platform:pkg", + "//web/service/shared:lib", + "//web/service/shared:lib_types", "package.json", "tsconfig.json", ], diff --git a/web/service/submitqueue/next.config.ts b/web/service/submitqueue/next.config.ts index bdddb6bf7..1601087ce 100644 --- a/web/service/submitqueue/next.config.ts +++ b/web/service/submitqueue/next.config.ts @@ -13,7 +13,7 @@ const nextConfig: NextConfig = { outputFileTracingExcludes: { "*": ["**/node_modules/**"], }, - transpilePackages: ["@submitqueue/api", "@submitqueue/web-submitqueue"], + transpilePackages: ["@submitqueue/web-platform", "@submitqueue/web-service-shared", "@submitqueue/api", "@submitqueue/web-submitqueue"], }; export default nextConfig; diff --git a/web/service/submitqueue/package.json b/web/service/submitqueue/package.json index d7df6d6cf..aa6049bdf 100644 --- a/web/service/submitqueue/package.json +++ b/web/service/submitqueue/package.json @@ -24,7 +24,8 @@ "pino": "^10.4.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "server-only": "^0.0.1" + "server-only": "^0.0.1", + "@submitqueue/web-service-shared": "0.0.0" }, "devDependencies": { "@types/node": "22.18.6", diff --git a/web/service/submitqueue/src/app/healthz/route.ts b/web/service/submitqueue/src/app/healthz/route.ts index b7a9ee88b..118defcba 100644 --- a/web/service/submitqueue/src/app/healthz/route.ts +++ b/web/service/submitqueue/src/app/healthz/route.ts @@ -1,4 +1,4 @@ -import { createNextHealthRoute } from "../../next/health"; +import { createNextHealthRoute } from "@submitqueue/web-service-shared/health"; import { loadAuthConfiguration } from "../../server/auth"; import { loadCursorSecret, loadGatewayConfiguration } from "../../server/config"; import { pingReferenceGateway } from "../../server/gateway"; diff --git a/web/service/submitqueue/src/next/page.tsx b/web/service/submitqueue/src/next/page.tsx index eaf618b66..64fa3fcd3 100644 --- a/web/service/submitqueue/src/next/page.tsx +++ b/web/service/submitqueue/src/next/page.tsx @@ -4,7 +4,7 @@ import { connection } from "next/server"; import { cache } from "react"; import { SubmitQueueApp } from "@submitqueue/web-submitqueue"; import type { SubmitQueueWeb, WebSearchParams } from "@submitqueue/web-submitqueue/server"; -import { NextNavigationProvider } from "./navigation"; +import { NextNavigationProvider } from "@submitqueue/web-service-shared/navigation"; export interface NextPageProps { params: Promise<{ path?: string[] }>; diff --git a/web/service/submitqueue/src/server/auth.test.ts b/web/service/submitqueue/src/server/auth.test.ts index c7bfd20a9..0f1038dc8 100644 --- a/web/service/submitqueue/src/server/auth.test.ts +++ b/web/service/submitqueue/src/server/auth.test.ts @@ -1,17 +1,9 @@ import { describe, expect, it } from "vitest"; -import { - BASIC_AUTH_USERNAME, - isAuthorized, - loadAuthConfiguration, -} from "./auth"; +import { authenticationChallenge, loadAuthConfiguration } from "./auth"; const token = "test"; -function basic(username: string, password: string): string { - return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; -} - describe("loadAuthConfiguration", () => { it("loads the local demo password", () => { expect(loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: token })).toEqual({ @@ -26,29 +18,8 @@ describe("loadAuthConfiguration", () => { }); }); -describe("isAuthorized", () => { - it("accepts the configured credentials", () => { - expect( - isAuthorized(basic("test", "test"), { token }), - ).toBe(true); - }); - - it.each([ - ["missing header", null], - ["wrong scheme", `Bearer ${token}`], - ["malformed base64", "Basic !!!"], - ["missing separator", `Basic ${Buffer.from("submitqueue").toString("base64")}`], - ["wrong user", basic("operator", token)], - ["previous demo user", basic("submitqueue", token)], - ["wrong password", basic(BASIC_AUTH_USERNAME, "b".repeat(32))], - ])("rejects %s", (_name, authorization) => { - expect(isAuthorized(authorization, { token })).toBe(false); - }); - - it("allows colons in the password", () => { - const password = `${token}:suffix`; - expect( - isAuthorized(basic(BASIC_AUTH_USERNAME, password), { token: password }), - ).toBe(true); - }); +it("uses the host-specific authentication realm", () => { + expect(authenticationChallenge().headers.get("www-authenticate")).toBe( + 'Basic realm="SubmitQueue demo", charset="UTF-8"', + ); }); diff --git a/web/service/submitqueue/src/server/auth.ts b/web/service/submitqueue/src/server/auth.ts index 02bbf68c8..189eabcc6 100644 --- a/web/service/submitqueue/src/server/auth.ts +++ b/web/service/submitqueue/src/server/auth.ts @@ -1,6 +1,6 @@ import "server-only"; -import { createHash, timingSafeEqual } from "node:crypto"; +import { isAuthorized as checkCredentials, authenticationChallenge as challenge } from "@submitqueue/web-service-shared/auth"; export const BASIC_AUTH_USERNAME = "test"; export const BASIC_AUTH_REALM = "SubmitQueue demo"; @@ -19,65 +19,10 @@ export function loadAuthConfiguration( return { token }; } -function decodeBasicCredentials( - authorization: string | null, -): Readonly<{ username: string; password: string }> | undefined { - if (authorization === null) { - return undefined; - } - - const [scheme, encoded, ...extra] = authorization.trim().split(/\s+/u); - if (scheme?.toLowerCase() !== "basic" || !encoded || extra.length !== 0) { - return undefined; - } - if ( - !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded) || - encoded.length % 4 === 1 - ) { - return undefined; - } - - const decoded = Buffer.from(encoded, "base64").toString("utf8"); - - const separator = decoded.indexOf(":"); - if (separator < 0) { - return undefined; - } - return { - username: decoded.slice(0, separator), - password: decoded.slice(separator + 1), - }; -} - -function digest(value: string): Buffer { - return createHash("sha256").update(value, "utf8").digest(); -} - -function securelyEqual(actual: string, expected: string): boolean { - return timingSafeEqual(digest(actual), digest(expected)); -} - -export function isAuthorized( - authorization: string | null, - configuration: AuthConfiguration, -): boolean { - const credentials = decodeBasicCredentials(authorization); - if (!credentials) { - return false; - } - return ( - securelyEqual(credentials.username, BASIC_AUTH_USERNAME) && - securelyEqual(credentials.password, configuration.token) - ); +export function isAuthorized(authorization: string | null, configuration: AuthConfiguration): boolean { + return checkCredentials(authorization, { ...configuration, username: BASIC_AUTH_USERNAME }); } export function authenticationChallenge(): Response { - return new Response("Authentication required", { - status: 401, - headers: { - "Cache-Control": "no-store", - "Content-Type": "text/plain; charset=utf-8", - "WWW-Authenticate": `Basic realm="${BASIC_AUTH_REALM}", charset="UTF-8"`, - }, - }); + return challenge(BASIC_AUTH_REALM); } diff --git a/web/service/submitqueue/src/server/config.ts b/web/service/submitqueue/src/server/config.ts index dd7089fb1..aadcc21b3 100644 --- a/web/service/submitqueue/src/server/config.ts +++ b/web/service/submitqueue/src/server/config.ts @@ -1,6 +1,6 @@ import "server-only"; -import { parseGatewayBaseUrl } from "./transport"; +import { parseServiceBaseUrl } from "@submitqueue/web-service-shared/transport"; export const GATEWAY_DEADLINE_MS = 5_000; export const GATEWAY_HEALTH_DEADLINE_MS = 1_000; @@ -18,7 +18,7 @@ export function loadGatewayConfiguration(environment: Environment = process.env) throw new Error("SUBMITQUEUE_GATEWAY_URL is required"); } const allowPlaintext = environment.SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT === "true"; - return { baseUrl: parseGatewayBaseUrl(rawBaseUrl, allowPlaintext), allowPlaintext }; + return { baseUrl: parseServiceBaseUrl(rawBaseUrl, allowPlaintext), allowPlaintext }; } /** The key that signs pagination cursors; independent of any viewer credential so either can rotate alone. */ diff --git a/web/service/submitqueue/src/server/gateway.ts b/web/service/submitqueue/src/server/gateway.ts index 8c691d730..ccf31e2a7 100644 --- a/web/service/submitqueue/src/server/gateway.ts +++ b/web/service/submitqueue/src/server/gateway.ts @@ -2,7 +2,7 @@ import "server-only"; import { createClient, type Client } from "@connectrpc/connect"; import { SubmitQueueGateway } from "@submitqueue/api/submitqueue/gateway"; -import { newConnectTransport } from "./transport"; +import { newConnectTransport } from "@submitqueue/web-service-shared/transport"; import { GATEWAY_DEADLINE_MS, GATEWAY_HEALTH_DEADLINE_MS, loadGatewayConfiguration } from "./config"; let client: Client | undefined; diff --git a/web/service/submitqueue/vitest.config.ts b/web/service/submitqueue/vitest.config.ts index 35ed019bb..3896ccd00 100644 --- a/web/service/submitqueue/vitest.config.ts +++ b/web/service/submitqueue/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ environment: "node", server: { deps: { - inline: ["@submitqueue/web-submitqueue"], + inline: ["@submitqueue/web-service-shared", "@submitqueue/web-submitqueue"], }, }, }, diff --git a/web/submitqueue/BUILD.bazel b/web/submitqueue/BUILD.bazel index 5045b0905..fd0ba6d2e 100644 --- a/web/submitqueue/BUILD.bazel +++ b/web/submitqueue/BUILD.bazel @@ -1,118 +1,35 @@ -load("@aspect_rules_js//npm:defs.bzl", "npm_package") -load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin") load("@npm//web/submitqueue:vitest/package_json.bzl", vitest = "bin") +load("//web/tool:library.bzl", "web_library") package(default_visibility = ["//web:__subpackages__"]) npm_link_all_packages(name = "node_modules") -_SRCS = glob( - [ - "**/*.ts", - "**/*.tsx", - ], - exclude = [ - "**/*.test.ts", - "**/*.test.tsx", - "dist/**", - "node_modules/**", - "test-setup.ts", - "vitest.config.ts", - ], -) - -_TESTS = glob([ - "**/*.test.ts", - "**/*.test.tsx", -]) - -ts_config( - name = "tsconfig", - src = "tsconfig.json", -) - -ts_config( - name = "tsconfig_build", - src = "tsconfig.build.json", - deps = [":tsconfig"], -) - -ts_project( - name = "lib", - srcs = _SRCS, - declaration = True, - declaration_map = True, - no_emit = False, - out_dir = "dist", - root_dir = ".", - transpiler = "tsc", - tsconfig = ":tsconfig_build", - deps = [ - ":node_modules/@opentelemetry/api", - ":node_modules/@types/node", - ":node_modules/@types/react", - ":node_modules/pino", - ":node_modules/react", - ], -) - -ts_project( - name = "typecheck", - srcs = _SRCS + _TESTS + [ - "test-setup.ts", - "vitest.config.ts", +web_library( + assets = [ + "view/styles.css", + "README.md", ], - declaration = True, - no_emit = True, - tsconfig = ":tsconfig", - deps = [ + eslint = eslint, + package = "@submitqueue/web-submitqueue", + test_deps = [ ":node_modules/@connectrpc/connect", - ":node_modules/@opentelemetry/api", ":node_modules/@testing-library/jest-dom", ":node_modules/@testing-library/react", - ":node_modules/@types/node", - ":node_modules/@types/react", ":node_modules/@types/react-dom", ":node_modules/jsdom", - ":node_modules/pino", - ":node_modules/react", ":node_modules/react-dom", ":node_modules/vitest", ], -) - -npm_package( - name = "pkg", - package = "@submitqueue/web-submitqueue", - packable = True, - srcs = [ - ":lib", - ":lib_types", - "view/styles.css", - "README.md", - "package.json", - ], -) - -vitest.vitest_test( - name = "test", - args = [ - "run", - "--config", - "vitest.config.ts", - ], - chdir = package_name(), - data = _SRCS + _TESTS + [ - ":node_modules", - ":node_modules/vitest", - "//web:node_modules/@opentelemetry/api", - "//web:node_modules/pino", - "//web:node_modules/react", - "//web:node_modules/react-dom", - # jest-dom resolves its vitest peer from the workspace root. - "//web:node_modules/vitest", - "test-setup.ts", - "vitest.config.ts", + vitest = vitest, + deps = [ + ":node_modules/@opentelemetry/api", + ":node_modules/@submitqueue/web-platform", + ":node_modules/@types/node", + ":node_modules/@types/react", + ":node_modules/pino", + ":node_modules/react", ], ) diff --git a/web/submitqueue/core/encoding.test.ts b/web/submitqueue/core/encoding.test.ts index b83bc5ce7..2fcf56062 100644 --- a/web/submitqueue/core/encoding.test.ts +++ b/web/submitqueue/core/encoding.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { safeInt64ToNumber } from "./int64.js"; -import { decodePathSegment, encodePathSegment } from "./path-segment.js"; +import { decodePathSegment, encodePathSegment } from "@submitqueue/web-platform/path-segment"; describe("safeInt64ToNumber", () => { it.each([ diff --git a/web/submitqueue/core/paths.test.ts b/web/submitqueue/core/paths.test.ts index b651a6703..470fff634 100644 --- a/web/submitqueue/core/paths.test.ts +++ b/web/submitqueue/core/paths.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { decodePathSegment, WebPaths } from "./paths"; +import { decodePathSegment } from "@submitqueue/web-platform/path-segment"; +import { WebPaths } from "./paths"; describe("WebPaths", () => { it("keeps the queue and complete sqid readable in the request path", () => { diff --git a/web/submitqueue/core/paths.ts b/web/submitqueue/core/paths.ts index 581be6807..d01976452 100644 --- a/web/submitqueue/core/paths.ts +++ b/web/submitqueue/core/paths.ts @@ -1,6 +1,4 @@ -import { encodePathSegment } from "./path-segment.js"; - -export { decodePathSegment } from "./path-segment.js"; +import { encodePathSegment } from "@submitqueue/web-platform/path-segment"; export interface RequestsPathOptions { pageToken?: string | null; diff --git a/web/submitqueue/core/route.ts b/web/submitqueue/core/route.ts index 6ed18c3b0..60de0be50 100644 --- a/web/submitqueue/core/route.ts +++ b/web/submitqueue/core/route.ts @@ -1,4 +1,4 @@ -import { decodePathSegment } from "./path-segment.js"; +import { decodePathSegment } from "@submitqueue/web-platform/path-segment"; /** Raw query parameters of a page URL, as frameworks commonly expose them. */ export type WebSearchParams = Readonly>; diff --git a/web/submitqueue/eslint.config.mjs b/web/submitqueue/eslint.config.mjs new file mode 100644 index 000000000..6459dc7c8 --- /dev/null +++ b/web/submitqueue/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from "../tool/eslint.config.mjs"; diff --git a/web/submitqueue/index.ts b/web/submitqueue/index.ts index e26da0af8..9a5220430 100644 --- a/web/submitqueue/index.ts +++ b/web/submitqueue/index.ts @@ -5,7 +5,7 @@ export { SubmitQueueApp, SubmitQueuePage, SubmitQueueShell, SubmitQueueStatePage, type SubmitQueuePageProps, type SubmitQueueShellProps, } from "./view/index.js"; -export { WebNavigationProvider, type WebLinkProps, type WebNavigation } from "./view/navigation.js"; +export { WebNavigationProvider, type WebLinkProps, type WebNavigation } from "@submitqueue/web-platform"; export type { ChangeDetailModel, ChangePageModel, ChangeSubmissionModel, HistoryEventModel, HistoryEventType, LoadResult, QueueDirectoryPageModel, QueueModel, QueueRequestsPageModel, RequestDetailModel, RequestListModel, diff --git a/web/submitqueue/package.json b/web/submitqueue/package.json index fd86246a1..bb170597c 100644 --- a/web/submitqueue/package.json +++ b/web/submitqueue/package.json @@ -31,7 +31,7 @@ }, "scripts": { "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json", - "lint": "tsc --noEmit", + "lint": "eslint .", "test": "vitest run", "typecheck": "tsc --noEmit" }, @@ -54,5 +54,8 @@ "react-dom": "^19.1.0", "typescript": "^5.8.3", "vitest": "4.0.7" + }, + "dependencies": { + "@submitqueue/web-platform": "0.0.0" } } diff --git a/web/submitqueue/view/components.test.tsx b/web/submitqueue/view/components.test.tsx index 6a4de86a4..f709719f2 100644 --- a/web/submitqueue/view/components.test.tsx +++ b/web/submitqueue/view/components.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { WebNavigationProvider } from "./navigation.js"; +import { WebNavigationProvider } from "@submitqueue/web-platform"; import { WebApp } from "./web-app.js"; import { ChangeSubmissions, QueueDirectory, RequestDetail, RequestList, RequestListView, RequestStatus } from "./components.js"; import type { ChangeDetailModel, RequestDetailModel, RequestListModel } from "../entity/index.js"; diff --git a/web/submitqueue/view/components.tsx b/web/submitqueue/view/components.tsx index 588dbd832..9ea9f99ff 100644 --- a/web/submitqueue/view/components.tsx +++ b/web/submitqueue/view/components.tsx @@ -2,7 +2,7 @@ import { ErrorState } from "./error-state.js"; import { useLastSuccessfulData } from "./last-successful-data.js"; -import { Timestamp } from "./timestamp.js"; +import { Timestamp } from "@submitqueue/web-platform"; import { useWebApp, WebLink } from "./web-app.js"; import { useState, type ReactNode } from "react"; import type { diff --git a/web/submitqueue/view/pages.test.tsx b/web/submitqueue/view/pages.test.tsx index bf9359b09..164faea8d 100644 --- a/web/submitqueue/view/pages.test.tsx +++ b/web/submitqueue/view/pages.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { WebNavigationProvider } from "./navigation.js"; +import { WebNavigationProvider } from "@submitqueue/web-platform"; import { SubmitQueueApp } from "./app.js"; import { SubmitQueuePage, SubmitQueueShell, SubmitQueueStatePage } from "./pages.js"; import { createSubmitQueueWeb } from "../module.js"; diff --git a/web/submitqueue/view/pages.tsx b/web/submitqueue/view/pages.tsx index acb8ac46d..f55b78932 100644 --- a/web/submitqueue/view/pages.tsx +++ b/web/submitqueue/view/pages.tsx @@ -2,7 +2,7 @@ import { WebRefreshControl } from "./refresh-control.js"; import { WebShell, type WebShellProps } from "./shell.js"; -import { Timestamp } from "./timestamp.js"; +import { Timestamp } from "@submitqueue/web-platform"; import { WebLink } from "./web-app.js"; import { ChangeSubmissions, LastSuccessfulData, QueueDirectory, RequestDetailView, RequestListView } from "./components.js"; import type { SubmitQueuePageModel } from "../entity/index.js"; diff --git a/web/submitqueue/view/refresh-control.tsx b/web/submitqueue/view/refresh-control.tsx index 39a02d0dc..f665abab0 100644 --- a/web/submitqueue/view/refresh-control.tsx +++ b/web/submitqueue/view/refresh-control.tsx @@ -1,7 +1,7 @@ "use client"; import type { WebRefreshModel } from "../entity/index.js"; -import { AutoRefresh } from "./auto-refresh.js"; +import { AutoRefresh } from "@submitqueue/web-platform"; import { useWebApp } from "./web-app.js"; /** Refresh button and polling status for the page rendered by the enclosing `WebApp`; renders nothing outside one. */ diff --git a/web/submitqueue/view/styles.css b/web/submitqueue/view/styles.css index ea91713c7..883cadd6a 100644 --- a/web/submitqueue/view/styles.css +++ b/web/submitqueue/view/styles.css @@ -1,102 +1,4 @@ -/* - * SubmitQueue web UI styles, scoped to .sq-app so unrelated host markup is - * never restyled. The --sq-* custom properties are the public theming - * contract: override them on .sq-app. - */ -.sq-app { - color-scheme: light; - --sq-surface: #fff; - --sq-surface-muted: #f5f6f8; - --sq-text: #202327; - --sq-muted: #626a74; - --sq-border: #e3e6e9; - --sq-accent: #175fc5; - --sq-accent-soft: #edf3ff; - --sq-success: #187247; - --sq-success-soft: #edf8f1; - --sq-warning: #8b590f; - --sq-warning-soft: #fff5e7; - --sq-danger: #b33535; - --sq-danger-soft: #fff0ef; - --sq-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --sq-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; - font-family: var(--sq-font); -} -.sq-app[data-theme="dark"] { - color-scheme: dark; - --sq-surface: #16181b; - --sq-surface-muted: #202328; - --sq-text: #e8eaed; - --sq-muted: #a0a7b0; - --sq-border: #33373d; - --sq-accent: #6ea8ff; - --sq-accent-soft: #1c2a40; - --sq-success: #5cc28a; - --sq-success-soft: #18301f; - --sq-warning: #e0a84a; - --sq-warning-soft: #36290f; - --sq-danger: #ff7b72; - --sq-danger-soft: #3a1f1e; -} -@media (prefers-color-scheme: dark) { - .sq-app[data-theme="system"] { - color-scheme: dark; - --sq-surface: #16181b; - --sq-surface-muted: #202328; - --sq-text: #e8eaed; - --sq-muted: #a0a7b0; - --sq-border: #33373d; - --sq-accent: #6ea8ff; - --sq-accent-soft: #1c2a40; - --sq-success: #5cc28a; - --sq-success-soft: #18301f; - --sq-warning: #e0a84a; - --sq-warning-soft: #36290f; - --sq-danger: #ff7b72; - --sq-danger-soft: #3a1f1e; - } -} -.sq-app, .sq-app * { box-sizing: border-box; } -.sq-app { background: var(--sq-surface); color: var(--sq-text); font-size: 14px; line-height: 1.5; } -.sq-app a { color: var(--sq-accent); text-decoration: none; overflow-wrap: anywhere; } -.sq-app a:hover { text-decoration: underline; } -.sq-app a:focus-visible, .sq-app button:focus-visible, .sq-app input:focus-visible, .sq-app select:focus-visible, .sq-app summary:focus-visible { - outline: 3px solid var(--sq-accent); outline-offset: 3px; -} -.sq-app h1 { font-size: 26px; line-height: 1.25; margin: 0 0 10px; letter-spacing: -.5px; } -.sq-app h2 { font-size: 16px; margin: 0 0 12px; } -.sq-app p { margin: 8px 0; } -.sq-app button, .sq-app input, .sq-app select { - font: inherit; border: 1px solid var(--sq-border); border-radius: 6px; - color: var(--sq-text); background: var(--sq-surface); padding: 8px 12px; -} -.sq-app button, .sq-app summary { cursor: pointer; } -.sq-app button:disabled { cursor: default; opacity: .65; } -.sq-app button:hover:not(:disabled) { background: var(--sq-surface-muted); } -.sq-app input { min-width: 0; } -.sq-app.site-frame { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; } -.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: min(1200px, calc(100% - 48px)); margin-inline: auto; } -.sq-app .site-header { min-height: 80px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--sq-border); } -.sq-app .brand { display: flex; align-items: center; gap: 9px; color: var(--sq-text); font-weight: 600; font-size: 18px; } -.sq-app .brand-mark { background: var(--sq-text); color: var(--sq-surface); border-radius: 6px; padding: 5px 7px; font-size: 12px; } -.sq-app .environment-pill, .sq-app .site-footer, .sq-app .sq-secondary { color: var(--sq-muted); font-size: 12px; } -.sq-app .site-footer { padding: 20px 0; border-top: 1px solid var(--sq-border); } -.sq-app .shell { padding-block: 28px 48px; } -.sq-app .centered-state { min-height: 100vh; display: grid; place-items: center; } -.sq-app .page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; } -.sq-app .page-heading p { color: var(--sq-muted); font-size: 12px; } -.sq-app .eyebrow { font-size: 12px; margin: 0 0 10px; color: var(--sq-muted); } -.sq-app .page-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; } -.sq-app .sq-refresh { display: flex; align-items: center; gap: 10px; color: var(--sq-muted); font-size: 12px; } -.sq-app .sq-error { padding: 16px; border-radius: 6px; background: var(--sq-danger-soft); } -.sq-app .sq-error p { overflow-wrap: anywhere; } -.sq-app .button-link { display: inline-block; margin-top: 16px; } -.sq-app .state-card { max-width: 700px; } -@media (max-width: 680px) { -.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: calc(100% - 32px); } -.sq-app .site-header { gap: 12px; flex-wrap: wrap; padding-block: 14px; } -.sq-app button, .sq-app select, .sq-app input { min-height: 44px; } -} +@import "@submitqueue/web-platform/styles.css"; /* SubmitQueue pages. */ .sq-app .sq-detail-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; } @@ -108,32 +10,12 @@ .sq-app .sq-toolbar { display: flex; justify-content: space-between; gap: 14px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; } .sq-app .sq-toolbar label { display: flex; align-items: center; gap: 10px; color: var(--sq-muted); font-size: 12px; flex-wrap: wrap; } .sq-app .sq-toolbar input { width: 240px; max-width: 100%; } -.sq-app .sq-table-scroll { overflow-x: auto; } -.sq-app table { width: 100%; border-collapse: collapse; text-align: left; } -.sq-app th { font-weight: 500; color: var(--sq-muted); font-size: 12px; padding: 10px 12px; border-bottom: 1px solid var(--sq-border); } -.sq-app td { padding: 18px 12px; border-bottom: 1px solid var(--sq-border); vertical-align: top; } -.sq-app th:first-child, .sq-app td:first-child { padding-left: 0; } -.sq-app th:last-child, .sq-app td:last-child { padding-right: 0; } -.sq-app td time { font-size: 12px; color: var(--sq-muted); } -.sq-app .sq-table-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 18px; font-size: 12px; color: var(--sq-muted); } .sq-app .sq-changes { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } .sq-app .sq-changes code, .sq-app .sq-changes a, .sq-app .sq-version { font: 12px var(--sq-font-mono); overflow-wrap: anywhere; } -.sq-app .sq-status, .sq-app .sq-event { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 12px; color: var(--sq-accent); background: var(--sq-accent-soft); white-space: nowrap; } -.sq-app .sq-status[data-tone="neutral"] { color: var(--sq-muted); background: var(--sq-surface-muted); } -.sq-app .sq-status[data-tone="success"] { color: var(--sq-success); background: var(--sq-success-soft); } -.sq-app .sq-status[data-tone="danger"] { color: var(--sq-danger); background: var(--sq-danger-soft); } -.sq-app .sq-status[data-tone="warning"] { color: var(--sq-warning); background: var(--sq-warning-soft); } .sq-app .sq-row-error { color: var(--sq-danger); font-size: 12px; max-width: 36ch; overflow-wrap: anywhere; } -.sq-app .sq-empty { padding: 35px 0; color: var(--sq-muted); } -.sq-app .sq-breadcrumb { display: flex; gap: 10px; flex-wrap: wrap; color: var(--sq-muted); font-size: 12px; margin-bottom: 24px; } .sq-app .sq-tabs { display: flex; gap: 25px; border-bottom: 1px solid var(--sq-border); margin-bottom: 24px; } .sq-app .sq-tabs a { padding: 10px 0 12px; color: var(--sq-muted); } .sq-app .sq-tabs [aria-current="page"] { color: var(--sq-text); border-bottom: 2px solid var(--sq-text); } -.sq-app .sq-detail-columns { display: grid; grid-template-columns: 1fr 1.15fr; gap: 38px; } -.sq-app .sq-facts { margin: 0; } -.sq-app .sq-facts > div { padding: 11px 0; border-bottom: 1px solid var(--sq-border); } -.sq-app dt { color: var(--sq-muted); font-size: 12px; margin-bottom: 5px; } -.sq-app dd { margin: 0; overflow-wrap: anywhere; } .sq-app .sq-soft-panel { padding: 18px; background: var(--sq-surface-muted); border-radius: 8px; margin-bottom: 16px; } .sq-app .sq-soft-panel summary { margin-bottom: 10px; } .sq-app .sq-stage { list-style: none; display: flex; flex-wrap: wrap; gap: 9px; padding: 0; margin-top: 24px; color: var(--sq-muted); font-size: 12px; } @@ -151,7 +33,6 @@ .sq-app .sq-directory li p { color: var(--sq-muted); } .sq-app .sq-change-identity { display: flex; gap: 28px; flex-wrap: wrap; padding: 18px; border-radius: 8px; background: var(--sq-surface-muted); margin: 24px 0; } @media (max-width: 680px) { -.sq-app .sq-detail-columns { grid-template-columns: 1fr; gap: 20px; } .sq-app .sq-history__item { grid-template-columns: 1fr; gap: 8px; } .sq-app .sq-history__item .sq-row-error { grid-column: 1; } .sq-app .sq-metadata__entry { grid-template-columns: 1fr; gap: 4px; } diff --git a/web/submitqueue/view/web-app.test.tsx b/web/submitqueue/view/web-app.test.tsx index 44feb5906..243f7de7d 100644 --- a/web/submitqueue/view/web-app.test.tsx +++ b/web/submitqueue/view/web-app.test.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { WebPageModel } from "../entity/index.js"; -import { WebNavigationProvider, type WebNavigation } from "./navigation.js"; +import { WebNavigationProvider, type WebNavigation } from "@submitqueue/web-platform"; import { WebRefreshControl } from "./refresh-control.js"; import { useWebApp, WebApp, WebLink } from "./web-app.js"; diff --git a/web/submitqueue/view/web-app.tsx b/web/submitqueue/view/web-app.tsx index 5dc48a6b2..8b4dd10dd 100644 --- a/web/submitqueue/view/web-app.tsx +++ b/web/submitqueue/view/web-app.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useMemo, useRef, type ComponentType } from "react"; import type { WebPageModel } from "../entity/index.js"; -import { useWebNavigation, type WebLinkProps } from "./navigation.js"; +import { useWebNavigation, type WebLinkProps } from "@submitqueue/web-platform"; /** Client-side controls of the enclosing {@link WebApp}. */ export interface WebAppControls { diff --git a/web/test/package/BUILD.bazel b/web/test/package/BUILD.bazel index cdb9f8d2e..3ebc28ee8 100644 --- a/web/test/package/BUILD.bazel +++ b/web/test/package/BUILD.bazel @@ -10,6 +10,11 @@ npm_link_package( src = "//web/submitqueue:pkg", ) +npm_link_package( + name = "node_modules/@submitqueue/web-platform", + src = "//web/platform:pkg", +) + ts_config( name = "tsconfig", src = "tsconfig.json", @@ -23,6 +28,7 @@ ts_project( tsconfig = ":tsconfig", deps = [ ":node_modules/@submitqueue/web-submitqueue", + ":node_modules/@submitqueue/web-platform", "//web:node_modules/@bufbuild/protobuf", "//web:node_modules/@connectrpc/connect", "//web:node_modules/@opentelemetry/api", @@ -47,6 +53,7 @@ js_test( entry_point = "consumer-runtime.mjs", data = [ ":node_modules/@submitqueue/web-submitqueue", + ":node_modules/@submitqueue/web-platform", "//web/submitqueue:node_modules", "//web:node_modules/@bufbuild/protobuf", "//web:node_modules/@connectrpc/connect", diff --git a/web/test/package/consumer-runtime.mjs b/web/test/package/consumer-runtime.mjs index 9be7c5139..ee52b037d 100644 --- a/web/test/package/consumer-runtime.mjs +++ b/web/test/package/consumer-runtime.mjs @@ -21,6 +21,8 @@ const html = renderToStaticMarkup(createElement(SubmitQueueShell, { homeHref: "/ assert.ok(html.includes('href="/queues/demo-queue/request/demo-queue/1"')); assert.ok(html.includes('aria-label="Queue requests"')); const stylesheet = await readFile(new URL(import.meta.resolve("@submitqueue/web-submitqueue/styles.css")), "utf8"); -assert.ok(stylesheet.includes(".sq-app.site-frame")); +assert.ok(stylesheet.includes("@submitqueue/web-platform/styles.css")); +const commonStyles = await readFile(new URL(import.meta.resolve("@submitqueue/web-platform/styles.css")), "utf8"); +assert.ok(commonStyles.includes(".sq-app.site-frame")); assert.ok(stylesheet.includes(".sq-app .sq-history")); console.log("An ordinary Node/React host loaded and rendered the application with its packaged stylesheet."); diff --git a/web/test/package/pack_contents_test.py b/web/test/package/pack_contents_test.py index ed585d0b6..89bf15e57 100644 --- a/web/test/package/pack_contents_test.py +++ b/web/test/package/pack_contents_test.py @@ -8,7 +8,7 @@ # The module reads the gateway through a structural contract; the transport, # generated protos, and web framework belong to the host. -_FORBIDDEN_IMPORTS = ("@submitqueue/api", "@connectrpc/", "@bufbuild/", "next") +_FORBIDDEN_IMPORTS = ("@submitqueue/api", "@submitqueue/web-submitqueue", "@connectrpc/", "@bufbuild/", "next") # Modules the client-safe root entry must never reach, directly or transitively. _CLIENT_FORBIDDEN_MODULES = ("package/dist/controller/", "package/dist/module.js", "package/dist/server.js") @@ -69,8 +69,12 @@ def main() -> int: print(f"The package is missing: {', '.join(missing)}", file=sys.stderr) return 1 package = json.load(archive.extractfile("package/package.json")) - if "next" in package.get("peerDependencies", {}) or "next" in package.get("dependencies", {}): - print("The module must not require Next.js.", file=sys.stderr) + if any( + _imports_forbidden(name) + for group in ("peerDependencies", "dependencies", "optionalDependencies") + for name in package.get(group, {}) + ): + print("The core module must not require framework or transport dependencies.", file=sys.stderr) return 1 stylesheet = package.get("exports", {}).get("./styles.css") if stylesheet != "./view/styles.css" or stylesheet not in package.get("sideEffects", []): diff --git a/web/test/package/react18/BUILD.bazel b/web/test/package/react18/BUILD.bazel index 216621ca8..5082432d8 100644 --- a/web/test/package/react18/BUILD.bazel +++ b/web/test/package/react18/BUILD.bazel @@ -10,6 +10,7 @@ npm_link_all_packages(name = "node_modules") src = target, ) for package, target in { + "@submitqueue/web-platform": "//web/platform:pkg", "@submitqueue/web-submitqueue": "//web/submitqueue:pkg", }.items() ] @@ -20,6 +21,7 @@ js_test( name = "react18_runtime_test", data = [ ":node_modules", + ":node_modules/@submitqueue/web-platform", ":node_modules/@submitqueue/web-submitqueue", "//web:node_modules/@bufbuild/protobuf", "//web:node_modules/@connectrpc/connect", diff --git a/web/tool/BUILD.bazel b/web/tool/BUILD.bazel index a01ee0863..1c0ebade8 100644 --- a/web/tool/BUILD.bazel +++ b/web/tool/BUILD.bazel @@ -1,3 +1,13 @@ +load("@aspect_rules_js//js:defs.bzl", "js_library") + exports_files([ "generate_api.bzl", + "library.bzl", + "eslint.config.mjs", ]) + +js_library( + name = "eslint_config", + srcs = ["eslint.config.mjs"], + visibility = ["//web:__subpackages__"], +) diff --git a/web/tool/eslint.config.mjs b/web/tool/eslint.config.mjs new file mode 100644 index 000000000..b4e21d829 --- /dev/null +++ b/web/tool/eslint.config.mjs @@ -0,0 +1,27 @@ +import nextConfig from "../service/submitqueue/node_modules/eslint-config-next/dist/index.js"; + +const parser = nextConfig.find((config) => config.name === "next/typescript") + .languageOptions.parser; +const react = nextConfig.find((config) => config.plugins?.react).plugins.react; + +export default [ + { ignores: ["dist/**", "node_modules/**"] }, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { react }, + rules: { + "react/jsx-key": "error", + "react/jsx-no-duplicate-props": "error", + "no-unreachable": "error", + "no-dupe-args": "error", + }, + }, +]; diff --git a/web/tool/library.bzl b/web/tool/library.bzl new file mode 100644 index 000000000..0a3c5c265 --- /dev/null +++ b/web/tool/library.bzl @@ -0,0 +1,81 @@ +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") + +def web_library(package, vitest, eslint, deps, test_deps = [], assets = []): + """Builds, packages and checks a TypeScript library with the workspace toolchain.""" + sources = native.glob( + ["**/*.ts", "**/*.tsx"], + exclude = [ + "**/*.test.ts", + "**/*.test.tsx", + "dist/**", + "node_modules/**", + "test-setup.ts", + "vitest.config.ts", + "test/**", + ], + ) + tests = native.glob( + ["**/*.test.ts", "**/*.test.tsx", "test/**"], + allow_empty = True, + ) + config = native.glob(["test-setup.ts"], allow_empty = True) + ["vitest.config.ts"] + ts_config(name = "tsconfig", src = "tsconfig.json") + ts_config( + name = "tsconfig_build", + src = "tsconfig.build.json", + deps = [":tsconfig"], + ) + ts_project( + name = "lib", + srcs = sources, + declaration = True, + declaration_map = True, + no_emit = False, + out_dir = "dist", + root_dir = ".", + transpiler = "tsc", + tsconfig = ":tsconfig_build", + deps = deps, + ) + ts_project( + name = "typecheck", + srcs = sources + tests + config, + declaration = True, + no_emit = True, + tsconfig = ":tsconfig", + deps = deps + test_deps, + ) + npm_package( + name = "pkg", + package = package, + packable = True, + srcs = [":lib", ":lib_types", "package.json"] + assets, + ) + vitest.vitest_test( + name = "test", + args = ["run", "--config", "vitest.config.ts"], + chdir = native.package_name(), + data = sources + tests + config + [ + ":node_modules", + ":node_modules/vitest", + "//web:node_modules/react", + "//web:node_modules/react-dom", + # jest-dom resolves its vitest peer from the workspace root. + "//web:node_modules/vitest", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/pino", + ] + deps, + ) + eslint.eslint_test( + name = "lint", + args = ["."], + chdir = native.package_name(), + data = sources + tests + config + [ + "eslint.config.mjs", + "//web/tool:eslint_config", + "//web/service/submitqueue:node_modules/eslint", + "//web/service/submitqueue:node_modules/eslint-config-next", + "//web:node_modules/next", + ], + )