diff --git a/.claude/launch.json b/.claude/launch.json index 544754b0..325db153 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -13,6 +13,12 @@ ], "port": 3520 }, + { + "name": "sendline", + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "--cwd", "D:/Company/sendline", "dev", "--port=3610"], + "port": 3610 + }, { "name": "component-showcase", "runtimeExecutable": "bun", diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index ad2189d5..35fc6852 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -302,6 +302,17 @@ export async function startServer(opts: ServeOptions): Promise { // Keep generated artifacts under the single framework state directory. // Startup clears this cache and removes legacy PID-suffixed cache folders. const cacheDir = join(appRoot, ".wrnexus", "cache"); + + // Clear the cache BEFORE anything writes into it. This used to run several + // hundred lines below, which deleted the plugin virtual modules written just + // after this point -- so an app with a plugin (auth, say) lost its generated + // modules at every boot, while an app with none never noticed. + resetDevCache({ + rootDir: appRoot, + cacheDir, + enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1", + }); + const virtualDir = join(cacheDir, "virtual"); mkdirSync(virtualDir, { recursive: true }); for (const [index, module] of pluginContributions.virtualModules.entries()) { @@ -360,12 +371,6 @@ export async function startServer(opts: ServeOptions): Promise { ? createDevToolbarCollector() : undefined; - resetDevCache({ - rootDir: appRoot, - cacheDir, - enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1", - }); - // Compile every `.wrn` into ONE cache dir at the project root, instead of a // `.wrnexus/` next to each source file (and inside node_modules UI dirs). setCompileCacheDir(cacheDir); diff --git a/packages/dev-server/test/bundle-shared-state.test.ts b/packages/dev-server/test/bundle-shared-state.test.ts new file mode 100644 index 00000000..a5d3aab5 --- /dev/null +++ b/packages/dev-server/test/bundle-shared-state.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const repoRoot = join(import.meta.dir, "..", "..", ".."); +const publishScript = join(repoRoot, "scripts", "publish-packages.ts"); + +/** + * `@wrnexus/dev-server` ships two entries — `index` (the dev server bootstrap) + * and `serve-entry` (the request path that compiles `.wrn` files). They share + * `pipeline.ts`, which holds MUTABLE module state: `compileCacheDir`, set once + * at startup by the bootstrap, and `browserArtifactPaths`, populated during + * compilation and read when serving `/__wrnexus/client/*`. + * + * Bundling each entry independently gives each its own copy of that state. The + * bootstrap then sets a cache dir the compiler never sees, and the compiler + * records artifact paths the server never sees — so every client module 404s + * and `.wrn` compilation writes nothing. It works from source (one module + * instance) and fails only once published, which is why it reached a release. + */ +test("the publish build shares chunks so module state is not duplicated per entry", () => { + const source = readFileSync(publishScript, "utf8"); + + expect(source).not.toMatch(/splitting:\s*false/); + expect(source).toMatch(/splitting:\s*true/); +}); + +test("a built dev-server dist declares its mutable pipeline state exactly once", () => { + const dist = join(repoRoot, "packages", "dev-server", "dist"); + if (!existsSync(dist)) { + // The dist is a build artifact, absent on a clean checkout. The + // configuration assertion above is the guard that always runs. + return; + } + + const bundles = readdirSync(dist).filter((file) => file.endsWith(".js")); + expect(bundles.length).toBeGreaterThan(0); + + for (const declaration of [ + "compileCacheDir", + "browserArtifactPaths", + "serveWrnBrowserArtifact", + ]) { + const owners = bundles.filter((file) => + readFileSync(join(dist, file), "utf8").includes(declaration), + ); + expect({ declaration, owners }).toEqual({ declaration, owners: owners.slice(0, 1) }); + } +}); diff --git a/packages/security/src/cookies.ts b/packages/security/src/cookies.ts index 5ce36202..49a68eb9 100644 --- a/packages/security/src/cookies.ts +++ b/packages/security/src/cookies.ts @@ -21,8 +21,11 @@ export function secureCookieOptions( throw new SecurityError("WRN-SEC-COOKIE-HOST", "Host-only cookies cannot set Domain."); } return { - path: options.path ?? "/", ...options, + // After the spread, not before: `setSecureCookie` always forwards a `path` + // key, so an omitted path arrives as an explicit `undefined` that would + // otherwise overwrite this default and ship a cookie with no Path at all. + path: options.path ?? "/", domain: options.hostOnly ? undefined : options.domain, httpOnly: options.httpOnly ?? true, secure, diff --git a/packages/security/test/security.test.ts b/packages/security/test/security.test.ts index a77038a8..5427ab2f 100644 --- a/packages/security/test/security.test.ts +++ b/packages/security/test/security.test.ts @@ -5,6 +5,7 @@ import { isPrivateAddress, isTrustedHtml, secureJsonStringify, + secureCookieOptions, setSecureCookie, unwrapTrustedHtml, validateUrl, @@ -45,6 +46,44 @@ describe("@wrnexus/security", () => { ]); }); + test("defaults a cookie's Path to / when the caller omits one", () => { + // setSecureCookie always forwards a `path` key, so an omitted path arrives + // as `path: undefined`. If that lands after the default in the returned + // object it wins, and the cookie ships with NO Path -- which the browser + // then scopes to the request's directory, so a cookie set from + // /api/oauth/google is never sent to /api/oauth/google/callback. + const writes: unknown[][] = []; + const ctx = { + url: new URL("https://example.com"), + cookies: { set: (...args: unknown[]) => writes.push(args) }, + } as any; + + setSecureCookie(ctx, "oauth_state", "abc", { sameSite: "Lax", maxAge: 600 }); + + expect(writes).toHaveLength(1); + expect((writes[0]![2] as { path?: string }).path).toBe("/"); + }); + + test("keeps an explicitly requested cookie path", () => { + const writes: unknown[][] = []; + const ctx = { + url: new URL("https://example.com"), + cookies: { set: (...args: unknown[]) => writes.push(args) }, + } as any; + + setSecureCookie(ctx, "scoped", "abc", { path: "/admin" }); + + expect((writes[0]![2] as { path?: string }).path).toBe("/admin"); + }); + + test("secureCookieOptions defaults Path even when handed an explicit undefined", () => { + const options = secureCookieOptions({ url: new URL("https://example.com") } as any, { + path: undefined, + }); + + expect(options.path).toBe("/"); + }); + test("accepts repeated references while still rejecting cycles", () => { const shared = { value: 1 }; expect(() => assertSafeObject({ first: shared, second: shared })).not.toThrow(); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index cd9b6ca5..b34a405d 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -267,7 +267,14 @@ async function main() { external: [/^@wrnexus\//, /^bun:/, /^node:/], tsconfig: join(repoRoot, "tsconfig.json"), clean: true, - splitting: false, + // Shared modules must be emitted ONCE as a chunk both entries import, + // not inlined into each. Several packages hold mutable module state -- + // dev-server's compileCacheDir and browserArtifactPaths are set by one + // entry and read by another -- and duplicating the module duplicates the + // state, so the writer and the reader silently address different copies. + // This only manifests in a published build; from source there is one + // module instance and everything works. + splitting: true, sourcemap: false, shims: false, silent: true,