From bca9549f3aaebfacf00481fa7ba3e48be318d788 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 10 Aug 2026 00:17:25 +0530 Subject: [PATCH] finish CSS delivery and generated type remediation --- docs/framework-remediation-plan.md | 6 + .../app/types/wrnexus.generated.d.ts | 9 +- package.json | 4 +- packages/captcha/test/durable-stores.test.ts | 130 ++++++++++++++++++ packages/cli/src/build.ts | 19 +-- packages/cli/src/types.ts | 3 +- packages/cli/test/build-css-delivery.test.ts | 38 +++++ packages/dev-server/src/prod.ts | 3 + packages/dev-server/src/runtime.ts | 7 +- .../syntax/test/versioning-security.test.ts | 35 +++++ scripts/check-generated-types.ts | 41 ++++++ 11 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 packages/captcha/test/durable-stores.test.ts create mode 100644 packages/cli/test/build-css-delivery.test.ts create mode 100644 scripts/check-generated-types.ts diff --git a/docs/framework-remediation-plan.md b/docs/framework-remediation-plan.md index 9ac7a4e9..013a6b12 100644 --- a/docs/framework-remediation-plan.md +++ b/docs/framework-remediation-plan.md @@ -16,6 +16,12 @@ showcase-generation, typecheck, lint, formatting, public-API, visual-contract, security, and runtime-size gates. The only skipped test is the explicitly environment-dependent live PostgreSQL/MySQL test. +Production CSS delivery now links the selected palette/accent sheet (6,632 +decoded bytes for dark/violet) instead of the complete 74,764-byte theme +matrix. The compatibility `theme.css` endpoint remains available but is not +linked by generated pages. Generated application declarations are committed +and checked for drift by `check:production`. + Written 2026-08-09, after the 0.8.6 release. Every number here was measured against the completed tree, not estimated. Where a cause is not yet proven the item says so and makes proving it step one — nothing in this document diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts index afc7ac80..0a65aa11 100644 --- a/examples/basic-app/app/types/wrnexus.generated.d.ts +++ b/examples/basic-app/app/types/wrnexus.generated.d.ts @@ -13,15 +13,14 @@ declare namespace WRNexusGenerated { : never; type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown; type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? I : unknown; - type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "login" | "modal" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "test" | "ui"; - type ApiRoute = "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; + type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui"; + type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; type RealtimeRoute = "/realtime/chat" | "/realtime/hello"; type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY"; - type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.ui"; + type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui"; type QueueName = "welcome-email"; type CacheKey = "users"; interface Components { - "Modal": { props: Record; outputs: Record }; "Counter": { props: { "start"?: unknown; "label"?: unknown }; outputs: Record }; } interface ApiContracts { @@ -30,6 +29,8 @@ declare namespace WRNexusGenerated { "/api/users/ssr": { GET: ApiContract }; "/api/graphql-example": { POST: ApiContract }; "/api/typed-user": { POST: ApiContract }; + "/api/accounts": { GET: ApiContract }; + "/api/invite": { POST: ApiContract }; "/api/logout": { POST: ApiContract }; "/api/hello": { GET: ApiContract }; "/api/login": { POST: ApiContract }; diff --git a/package.json b/package.json index 815a8622..66a65eb0 100644 --- a/package.json +++ b/package.json @@ -43,12 +43,14 @@ "security:asvs": "node scripts/check-security-asvs.mjs", "generate:security-report": "node scripts/security-performance-audit.mjs --write", "check:public-api": "node scripts/check-public-api.mjs", + "check:generated-types": "bun run scripts/check-generated-types.ts", + "generate:example-types": "bun run packages/cli/src/index.ts generate types examples/basic-app", "generate:public-api": "node scripts/check-public-api.mjs --write", "check:ui-visual": "node scripts/check-ui-visual-contract.mjs", "generate:ui-visual": "node scripts/check-ui-visual-contract.mjs --write", "sbom": "node scripts/generate-sbom.mjs", "benchmark:framework": "node --experimental-transform-types scripts/benchmark-framework.mjs --write", - "check:production": "bun run check:workspace && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check:editor-compiler && bun run check:editor-language-server && bun run check:editor-extension && bun run check && bun run test:examples", + "check:production": "bun run check:workspace && bun run check:generated-types && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check:editor-compiler && bun run check:editor-language-server && bun run check:editor-extension && bun run check && bun run test:examples", "validate:staging": "node --experimental-transform-types scripts/test-package-integrity.mjs", "stage:packages": "bun run scripts/publish-packages.ts", "test:staged-consumers": "node scripts/test-staged-consumers.mjs", diff --git a/packages/captcha/test/durable-stores.test.ts b/packages/captcha/test/durable-stores.test.ts new file mode 100644 index 00000000..bfd35f1c --- /dev/null +++ b/packages/captcha/test/durable-stores.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + RedisCaptchaStore, + SqliteCaptchaStore, + managedCaptchaProvider, + type CaptchaChallengeRecord, + type CaptchaResponseTokenRecord, + type RedisCaptchaClient, + type SqliteDatabaseLike, +} from "../src/index.ts"; + +const challenge = (id: string, expiresAt = Date.now() + 60_000): CaptchaChallengeRecord => ({ + id, + provider: "self-hosted", + type: "text", + presentation: "visual", + action: "signup", + publicChallenge: { + id, + provider: "self-hosted", + type: "text", + presentation: "visual", + action: "signup", + prompt: "Type the text", + createdAt: Date.now(), + expiresAt, + responseField: "wrn-captcha-response", + }, + answerDigest: "digest", + answerSalt: "salt", + answerKind: "text", + caseSensitive: false, + attempts: 0, + maxAttempts: 3, + metadata: {}, + createdAt: Date.now(), + expiresAt, +}); + +const token = (tokenHash: string): CaptchaResponseTokenRecord => ({ + tokenHash, + provider: "self-hosted", + action: "signup", + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, +}); + +describe("CAPTCHA durable stores", () => { + test("SQLite atomically mutates, consumes, deletes, and collects records", async () => { + const db = new Database(":memory:"); + const store = new SqliteCaptchaStore(db as unknown as SqliteDatabaseLike); + await store.createChallenge(challenge("c1")); + expect((await store.incrementAttempts("c1", Date.now()))?.attempts).toBe(1); + expect(await store.consumeChallenge("c1", Date.now())).toMatchObject({ + consumedAt: expect.any(Number), + }); + expect(await store.consumeChallenge("c1", Date.now())).toBeUndefined(); + + await store.createToken(token("t1")); + expect(await store.consumeToken("t1", Date.now())).toMatchObject({ + consumedAt: expect.any(Number), + }); + expect(await store.consumeToken("t1", Date.now())).toBeUndefined(); + await store.deleteChallenge("c1"); + await store.deleteToken("t1"); + await store.createChallenge(challenge("expired", Date.now() - 1)); + await store.gc(Date.now()); + expect(await store.getChallenge("expired")).toBeUndefined(); + db.close(); + }); + + test("Redis fallback locking preserves one-time consumption without Lua", async () => { + const values = new Map(); + const redis: RedisCaptchaClient = { + get: (key) => values.get(key) ?? null, + set: (key, value) => void values.set(key, value), + del: (key) => (values.delete(key) ? 1 : 0), + }; + const store = new RedisCaptchaStore(redis, { prefix: "test:" }); + await store.createChallenge(challenge("c1")); + expect((await store.incrementAttempts("c1", Date.now()))?.attempts).toBe(1); + const consumed = await Promise.all([ + store.consumeChallenge("c1", Date.now()), + store.consumeChallenge("c1", Date.now()), + ]); + expect(consumed.filter(Boolean)).toHaveLength(1); + + await store.createToken(token("t1")); + const tokens = await Promise.all([ + store.consumeToken("t1", Date.now()), + store.consumeToken("t1", Date.now()), + ]); + expect(tokens.filter(Boolean)).toHaveLength(1); + await store.deleteChallenge("c1"); + await store.deleteToken("t1"); + expect(await store.getChallenge("c1")).toBeUndefined(); + }); + + test("managed provider separates public creation from secret verification", async () => { + const requests: RequestInit[] = []; + const provider = managedCaptchaProvider({ + baseUrl: "https://captcha.test/", + siteKey: "site", + secretKey: "secret", + fetch: (async (_url, init) => { + requests.push(init ?? {}); + return Response.json( + requests.length === 1 + ? { + id: "c1", + provider: "managed", + type: "text", + presentation: "visual", + action: "signup", + prompt: "Type", + createdAt: 1, + expiresAt: 2, + responseField: "token", + } + : { success: true, provider: "managed", action: "signup" }, + ); + }) as typeof fetch, + }); + await provider.createChallenge({ action: "signup" }); + await provider.verify({ action: "signup", providerToken: "answer" }); + expect(new Headers(requests[0]?.headers).has("authorization")).toBe(false); + expect(new Headers(requests[1]?.headers).get("authorization")).toBe("Bearer secret"); + }); +}); diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 8866abf2..0ed29244 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -566,14 +566,17 @@ export async function runBuild(appRoot: string): Promise { }, config.styles, ); - const combinedSource = `${frameworkStyles}\n${css}`; + // Theme tokens are request-selected and loaded separately. Keep only the + // shared UI primitives with application CSS so the full theme/accent + // matrix cannot leak back into the blocking stylesheet. + const combinedSource = `${uiStyles}\n${css}`; const combinedInput = join(distDir, ".wrnexus-combined.css"); writeFileSync(combinedInput, combinedSource, "utf8"); const combinedCss = await bundleCss(combinedInput, "production"); rmSync(combinedInput, { force: true }); assetHash.update(combinedCss); - // One blocking CSS request in production: tokens → UI → application CSS. - // The standalone framework.css remains available for apps without global CSS. + // One shared blocking request for UI + application CSS. The small active + // theme stylesheet is selected from the request cookie and loaded first. writeFileSync(join(distDir, "styles.css"), combinedCss, "utf8"); hasStyles = true; if (Buffer.byteLength(combinedCss, "utf8") <= INLINE_CSS_LIMIT_BYTES) { @@ -655,8 +658,8 @@ applyAuthzManifestEarly([${authzSetupEntries}]); const componentsLit = router.components .map((c) => { const v = `c${counter++}`; - imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(c.file))};`); - return `{ name: ${JSON.stringify(c.name)}, mod: ${v} }`; + imports.push(`import { render as ${v} } from ${JSON.stringify(importPathFor(c.file))};`); + return `{ name: ${JSON.stringify(c.name)}, mod: { render: ${v} } }`; }) .join(", "); console.log(`✓ Components: ${router.components.length}`); @@ -665,8 +668,8 @@ applyAuthzManifestEarly([${authzSetupEntries}]); const layoutsLit = router.layouts .map((l) => { const v = `c${counter++}`; - imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(l.file))};`); - return `{ name: ${JSON.stringify(l.name)}, mod: ${v} }`; + imports.push(`import { render as ${v} } from ${JSON.stringify(importPathFor(l.file))};`); + return `{ name: ${JSON.stringify(l.name)}, mod: { render: ${v} } }`; }) .join(", "); if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`); @@ -748,7 +751,7 @@ await createProductionServer( realtime: ${config.realtime ? JSON.stringify(config.realtime) : "undefined"}, publicDir: join(import.meta.dir, "public"), ${hasStyles ? `stylesPath: join(import.meta.dir, "styles.css"),` : ""} - ${hasStyles ? `stylesIncludeFramework: true,` : ""} + ${hasStyles ? `stylesIncludeUi: true,` : ""} ${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""} assetVersion: ${JSON.stringify(assetVersion)}, clientRuntimes: ${JSON.stringify(emittedPluginAssets.runtimes)}, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 3d06a88a..c15f04b7 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -75,7 +75,7 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions) }); writeFileSync( join(typeDir, "wrnexus.plugins.generated.d.ts"), - `// AUTO-GENERATED plugin type aggregation - do not edit.\n${references.join("\n")}\n`, + `// AUTO-GENERATED plugin type aggregation - do not edit.\n${references.length > 0 ? `${references.join("\n")}\n` : ""}`, "utf8", ); const docsDir = join(root, ".wrnexus", "documentation"); @@ -182,7 +182,6 @@ export function generateApplicationTypes( .map((name) => join(root, name)) .find(existsSync); const code = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit. -/* eslint-disable @typescript-eslint/no-empty-object-type */ declare namespace WRNexusGenerated { type ApiContract = T extends import("@wrnexus/core").DefinedEndpoint ? { input: I; output: O } diff --git a/packages/cli/test/build-css-delivery.test.ts b/packages/cli/test/build-css-delivery.test.ts new file mode 100644 index 00000000..e1b138e5 --- /dev/null +++ b/packages/cli/test/build-css-delivery.test.ts @@ -0,0 +1,38 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runBuild } from "../src/build.ts"; + +const scratchRoot = join(import.meta.dir, ".tmp-css-delivery"); +mkdirSync(scratchRoot, { recursive: true }); +afterAll(() => rmSync(scratchRoot, { recursive: true, force: true })); + +test("production ships active themes separately and retains one component style copy", async () => { + const root = mkdtempSync(join(scratchRoot, "app-")); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + mkdirSync(join(root, "app", "components"), { recursive: true }); + mkdirSync(join(root, "app", "styles"), { recursive: true }); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "css-delivery-fixture" })); + writeFileSync( + join(root, "wrnexus.config.ts"), + `export default { theme: { default: "dark", palette: "violet" }, styles: { entry: "app/styles/global.css" } };\n`, + ); + writeFileSync(join(root, "app", "styles", "global.css"), ".app-shell{display:block}\n"); + writeFileSync( + join(root, "app", "components", "Probe.wrn"), + `component Probe { style { .unique-server-style-marker{color:red} } view {

Probe

} }\n`, + ); + writeFileSync( + join(root, "app", "pages", "index.wrn"), + `import Probe from "../components/Probe.wrn"\npage Home { view {
} }\n`, + ); + + await runBuild(root); + const styles = readFileSync(join(root, "dist", "styles.css"), "utf8"); + const activeTheme = readFileSync(join(root, "dist", "theme", "dark", "violet.css"), "utf8"); + const server = readFileSync(join(root, "dist", "server.js"), "utf8"); + expect(styles).toContain(".app-shell"); + expect(styles).not.toContain("[data-theme="); + expect(activeTheme.length).toBeLessThan(10_000); + expect(server.match(/unique-server-style-marker/g)).toHaveLength(2); +}); diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 8e6d93cd..cb7a969b 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -91,6 +91,8 @@ export interface ProdOptions { inlineStyles?: string; /** `stylesPath` contains theme + UI + app CSS in cascade order. */ stylesIncludeFramework?: boolean; + /** `stylesPath` already contains the shared Wire UI stylesheet. */ + stylesIncludeUi?: boolean; /** Absolute path to the pre-built reactive runtime. */ reactivePath?: string; /** Absolute path to the on-demand component controller runtime. */ @@ -501,6 +503,7 @@ export function createProductionHandlers( i18n: opts.i18n, inlineStyles: opts.inlineStyles, stylesIncludeFramework: opts.stylesIncludeFramework, + stylesIncludeUi: opts.stylesIncludeUi, assetVersion: opts.assetVersion, clientRuntimes: opts.clientRuntimes, head: opts.head, diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index e7de2a9f..009e4704 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -160,6 +160,7 @@ export interface RuntimeDeps { hasFrameworkStyles?: boolean; /** App stylesheet already contains theme + UI CSS and is the only CSS request needed. */ stylesIncludeFramework?: boolean; + stylesIncludeUi?: boolean; /** Resolved theme config: enables `/__wrnexus/theme.css` + ``. */ theme?: ResolvedTheme; /** Resolved i18n bundle: enables `ctx.t`, ``, and `{t:key}` markers. */ @@ -940,12 +941,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers { // Order: theme tokens, then Wire UI, then the app stylesheet — so the app's // own CSS (loaded last) can override both the tokens and the UI classes. const headParts: string[] = []; - if (deps.hasFrameworkStyles && !deps.stylesIncludeFramework) { + if (!deps.theme && deps.hasFrameworkStyles && !deps.stylesIncludeFramework) { headParts.push( ``, ); } - if (deps.hasUi && !deps.hasFrameworkStyles && !deps.stylesIncludeFramework) { + if (deps.hasUi && !deps.stylesIncludeUi && !deps.stylesIncludeFramework) { headParts.push( ``, ); @@ -1847,7 +1848,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { if (accentName) { attrs.push(`data-accent="${accentName}"`); } - if (!deps.hasFrameworkStyles && !deps.stylesIncludeFramework) { + if (!deps.stylesIncludeFramework) { const themeHref = versionAssetUrl( activeThemeCssHref(themeName, accentName), deps.assetVersion, diff --git a/packages/syntax/test/versioning-security.test.ts b/packages/syntax/test/versioning-security.test.ts index a20f5f26..f9a45e27 100644 --- a/packages/syntax/test/versioning-security.test.ts +++ b/packages/syntax/test/versioning-security.test.ts @@ -38,4 +38,39 @@ describe("syntax contract and security diagnostics", () => { expect(codes).toContain("WRN-SEC-STRING-TIMER"); expect(codes).toContain("WRN-PERSIST-SENSITIVE"); }); + + test.each([ + [ + "server output call", + `component Bad { outputs { save() } functions { server function run(): void { output.save() } } view {
} }`, + "WRN-OUTPUT-SERVER-CALL", + ], + [ + "unknown output call", + `component Bad { functions { client function run(): void { output.missing() } } view { } }`, + "WRN-OUTPUT-UNKNOWN", + ], + [ + "server API in client function", + `component Bad { functions { client function run(): void { process.cwd() } } view { } }`, + "WRN-CLIENT-SERVER-API", + ], + [ + "browser API in server function", + `component Bad { functions { server function run(): void { document.title = "bad" } } view {
} }`, + "WRN-SERVER-BROWSER-API", + ], + [ + "non-serializable shared state", + `global store Bad { state { values: unknown = new Map() } }`, + "WRN-STATE-NON-SERIALIZABLE", + ], + [ + "unknown persisted state", + `global store Bad { state { value: string = "x" } persist { storage = "local" include = ["missing"] version = 1 } }`, + "WRN-PERSIST-UNKNOWN-FIELD", + ], + ])("diagnoses %s", (_name, source, expectedCode) => { + expect(diagnose(source).map((diagnostic) => diagnostic.code)).toContain(expectedCode); + }); }); diff --git a/scripts/check-generated-types.ts b/scripts/check-generated-types.ts new file mode 100644 index 00000000..eed217c6 --- /dev/null +++ b/scripts/check-generated-types.ts @@ -0,0 +1,41 @@ +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { generateApplicationTypesWithPlugins } from "../packages/cli/src/types.ts"; + +const root = process.cwd(); +const source = join(root, "examples", "basic-app"); +const scratchParent = join(root, ".wrnexus-type-check"); +const scratch = mkdtempSync(`${scratchParent}-`); +const generated = [ + join("app", "types", "wrnexus.generated.d.ts"), + join("app", "types", "wrnexus.plugins.generated.d.ts"), +]; + +try { + cpSync(source, scratch, { + recursive: true, + filter(path) { + const relative = path.slice(source.length).replaceAll("\\", "/"); + return !/(?:^|\/)(?:dist|node_modules|\.wrnexus(?:-[^/]*)?)(?:\/|$)/.test(relative); + }, + }); + await generateApplicationTypesWithPlugins(scratch); + + const stale = generated.filter((relative) => { + const committed = join(source, relative); + const expected = join(scratch, relative); + return ( + !existsSync(committed) || + !existsSync(expected) || + readFileSync(committed, "utf8") !== readFileSync(expected, "utf8") + ); + }); + if (stale.length) { + throw new Error( + `Generated application types are stale: ${stale.join(", ")}. Run \`bun run generate:example-types\`.`, + ); + } + console.log(`Generated application types match ${generated.length} committed artifacts.`); +} finally { + rmSync(scratch, { recursive: true, force: true }); +}