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",
+ ],
+ )