Refresh UI components and showcase; release updated framework packages
This commit is contained in:
1 parent
d800228585
commit
c77f5e1cec
293 files changed
+22544
-35778
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.65",
|
||||
"version": "0.8.66",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -64,7 +64,10 @@ export async function readPluginAsset(asset: ServedPluginAsset): Promise<string
|
||||
if (asset.source instanceof Uint8Array) return toBody(asset.source);
|
||||
if (!asset.entry) throw new Error(`Plugin asset '${asset.id}' has no source.`);
|
||||
|
||||
const shouldBundle = asset.runtime && (asset.bundle ?? /\.[cm]?tsx?$/.test(asset.entry));
|
||||
// Published runtimes are JavaScript too, often with relative split chunks.
|
||||
// Serve a self-contained browser entry unless the plugin explicitly opts out
|
||||
// (for example, prebuilt pdf.js modules whose worker protocol needs raw ESM).
|
||||
const shouldBundle = asset.runtime && (asset.bundle ?? /\.[cm]?[jt]sx?$/.test(asset.entry));
|
||||
if (!shouldBundle) return toBody(await readFile(asset.entry));
|
||||
|
||||
const result = await Bun.build({
|
||||
|
||||
@@ -896,7 +896,7 @@ function forbiddenOrigin(): Response {
|
||||
}
|
||||
|
||||
export interface UpgradeServer {
|
||||
upgrade(req: Request, opts: { data: WsData }): boolean;
|
||||
upgrade(req: Request, opts: { data: WsData; headers?: Headers }): boolean;
|
||||
/** Bun's per-request socket peer address (used for the non-spoofable client IP). */
|
||||
requestIP?(req: Request): { address: string } | null;
|
||||
}
|
||||
@@ -1258,44 +1258,50 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
|
||||
}
|
||||
|
||||
// --- Realtime: try to upgrade BEFORE the normal HTTP pipeline. ---
|
||||
// Upgrade only after the same authentication/authorization middleware as HTTP.
|
||||
const rt = router.matchRealtime(url.pathname);
|
||||
if (rt) {
|
||||
// Reject cross-site WebSocket handshakes (CSWSH) before doing any work.
|
||||
if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin());
|
||||
const mod = await loadModule(rt.route.file);
|
||||
let data: WsData;
|
||||
if (isRoomDefinition(mod.default)) {
|
||||
// Identify the connection: an authenticated session user id, else ?user=.
|
||||
const wsCtx = createContext(req, url);
|
||||
const sessionUser = wsCtx.session.get<{ id?: unknown }>("user");
|
||||
const user =
|
||||
sessionUser && typeof sessionUser === "object" && sessionUser.id != null
|
||||
? String(sessionUser.id)
|
||||
: (url.searchParams.get("user") ?? undefined);
|
||||
const query = Object.fromEntries(url.searchParams);
|
||||
// Room-level gate (e.g. require auth) — reject before accepting the socket.
|
||||
const allowed = await mod.default.handlers.authorize?.({
|
||||
user,
|
||||
query,
|
||||
headers: req.headers,
|
||||
});
|
||||
if (allowed === false) {
|
||||
return secure(new Response("Forbidden", { status: 403 }));
|
||||
const wsCtx = createContext(req, url);
|
||||
return runWithRequestContext(wsCtx, async () => {
|
||||
try {
|
||||
initializeRequestCache(wsCtx);
|
||||
wsCtx.ip = server.requestIP?.(req)?.address ?? undefined;
|
||||
wsCtx.locals.cspNonce = nonce;
|
||||
let data: WsData | undefined;
|
||||
const permit = crypto.randomUUID();
|
||||
const response = await runMiddleware(await resolveMiddleware(), wsCtx, async () => {
|
||||
const mod = await loadModule(rt.route.file);
|
||||
if (isRoomDefinition(mod.default)) {
|
||||
const sessionUser = wsCtx.session.get<{ id?: unknown }>("user");
|
||||
const authenticatedUser = wsCtx.user as { id?: unknown } | null;
|
||||
const id = authenticatedUser?.id ?? sessionUser?.id;
|
||||
// Query parameters are untrusted and must never supply an identity.
|
||||
const user = id == null ? undefined : String(id);
|
||||
const query = Object.fromEntries(url.searchParams);
|
||||
if (mod.default.handlers.security?.requireUser && !user)
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
const allowed = await mod.default.handlers.authorize?.({ user, query, headers: req.headers });
|
||||
if (allowed === false) return new Response("Forbidden", { status: 403 });
|
||||
data = { kind: "room", meta: { room: url.pathname, def: mod.default, query, user } };
|
||||
} else if (mod.websocket && typeof mod.websocket === "object") {
|
||||
data = { kind: "realtime", handler: mod.websocket as WsHandler };
|
||||
} else {
|
||||
return new Response("Realtime route needs a default defineRoom() or a `websocket` export", { status: 500 });
|
||||
}
|
||||
// A response lets post-processing middleware complete before upgrading.
|
||||
return new Response(null, { status: 204, headers: { "x-wrnexus-upgrade-permit": permit } });
|
||||
});
|
||||
const allowed = data && response.status === 204 && response.headers.get("x-wrnexus-upgrade-permit") === permit;
|
||||
response.headers.delete("x-wrnexus-upgrade-permit");
|
||||
const result = secure(withContextHeaders(wsCtx, response));
|
||||
if (!allowed) return result;
|
||||
if (server.upgrade(req, { data: data!, headers: result.headers })) return undefined;
|
||||
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
|
||||
} catch {
|
||||
return secure(new Response("Realtime connection failed", { status: 500 }));
|
||||
}
|
||||
data = { kind: "room", meta: { room: url.pathname, def: mod.default, query, user } };
|
||||
} else if (mod.websocket && typeof mod.websocket === "object") {
|
||||
data = { kind: "realtime", handler: mod.websocket as WsHandler };
|
||||
} else {
|
||||
return secure(
|
||||
new Response("Realtime route needs a default defineRoom() or a `websocket` export", {
|
||||
status: 500,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const upgraded = server.upgrade(req, { data });
|
||||
if (upgraded) return undefined; // Bun takes over the socket.
|
||||
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createContext(req, url);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const label = "published-runtime-chunk-loaded";
|
||||
@@ -0,0 +1,2 @@
|
||||
import { label } from "./chunk.js";
|
||||
globalThis.__pluginFixture = label;
|
||||
@@ -0,0 +1,2 @@
|
||||
import { label } from "./chunk.js";
|
||||
export { label };
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
mergePluginAssets,
|
||||
runtimeIdsFromMarkup,
|
||||
@@ -35,6 +36,23 @@ test("serves package assets with a safe content type", async () => {
|
||||
expect(await response?.text()).toContain("window.captcha");
|
||||
});
|
||||
|
||||
test("published JavaScript runtimes bundle relative chunks by default", async () => {
|
||||
const entry=fileURLToPath(new URL("./fixtures/plugin-runtime/entry.js",import.meta.url));
|
||||
const assets=mergePluginAssets([{id:"published",entry}],[]);
|
||||
const response=await servePluginAsset(assets,"/__wrnexus/assets/published.js","development");
|
||||
expect(response?.status).toBe(200);
|
||||
const source=await response!.text();
|
||||
expect(source).toContain("published-runtime-chunk-loaded");
|
||||
expect(source).not.toMatch(/from\s+["']\.\/chunk\.js["']/);
|
||||
});
|
||||
|
||||
test("explicit bundle false preserves prebuilt runtime modules", async () => {
|
||||
const entry=fileURLToPath(new URL("./fixtures/plugin-runtime/entry.js",import.meta.url));
|
||||
const assets=mergePluginAssets([{id:"raw",entry,bundle:false}],[]);
|
||||
const response=await servePluginAsset(assets,"/__wrnexus/assets/raw.js","development");
|
||||
expect(await response!.text()).toContain('from "./chunk.js"');
|
||||
});
|
||||
|
||||
test("resolves inline package migrations for the requested database", () => {
|
||||
const migrations = resolvePackageMigrations(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { defineRoom, type Middleware } from "@wrnexus/core";
|
||||
import { createProductionHandlers } from "../src/prod.ts";
|
||||
|
||||
const room = defineRoom({ security: { requireUser: true }, authorize: info => info.user === "signed-in-user" });
|
||||
const auth: Middleware = async (ctx, next) => {
|
||||
if (ctx.req.headers.get("cookie") === "test-session=valid") ctx.user = { id: "signed-in-user" };
|
||||
return next();
|
||||
};
|
||||
function handlers(middleware: Middleware[] = [auth]) {
|
||||
return createProductionHandlers({ pages: [], api: [], realtime: [{raw:"/realtime/updates",mod:{default:room}}], middleware, components:[], layouts:[] }, {});
|
||||
}
|
||||
function request(query = "", cookie = "test-session=valid", origin = "https://account.example.test") {
|
||||
return new Request("https://account.example.test/realtime/updates"+query, { headers: {host:"account.example.test", origin, cookie, upgrade:"websocket"} });
|
||||
}
|
||||
test("room upgrade receives middleware identity after post-processing completes", async () => {
|
||||
let finished = false;
|
||||
const post: Middleware = async (_, next) => { const res = await next(); finished = true; return new Response(res.body, { status:res.status, headers:res.headers }); };
|
||||
let upgraded = false;
|
||||
const response = await handlers([auth,post]).fetch(request(), { upgrade: (_, options) => {
|
||||
expect(finished).toBe(true);
|
||||
expect((options.data as any).meta.user).toBe("signed-in-user");
|
||||
expect(options.headers?.has("x-wrnexus-upgrade-permit")).toBe(false);
|
||||
upgraded = true; return true;
|
||||
} });
|
||||
expect(response).toBeUndefined(); expect(upgraded).toBe(true);
|
||||
});
|
||||
test("query user cannot impersonate an authenticated room member", async () => {
|
||||
const response = await handlers().fetch(request("?user=signed-in-user", ""), {upgrade:()=>{throw Error("must not upgrade")}});
|
||||
expect(response?.status).toBe(403);
|
||||
});
|
||||
test("middleware rejection and cross-site origins never upgrade", async () => {
|
||||
const server = {upgrade:()=>{throw Error("must not upgrade")}};
|
||||
expect((await handlers([()=>new Response("denied",{status:401})]).fetch(request(),server))?.status).toBe(401);
|
||||
expect((await handlers().fetch(request("", "test-session=valid", "https://evil.example"),server))?.status).toBe(403);
|
||||
});
|
||||
test("post-processing rejection overrides room authorization", async () => {
|
||||
const deny: Middleware = async (_,next)=>{await next();return new Response("denied",{status:403})};
|
||||
expect((await handlers([auth,deny]).fetch(request(),{upgrade:()=>{throw Error("must not upgrade")}}))?.status).toBe(403);
|
||||
});
|
||||
|
||||
test("Bun accepts a real socket with the middleware-authenticated identity", async () => {
|
||||
const app = handlers();
|
||||
const server = Bun.serve({ port:0, fetch:app.fetch, websocket:app.websocket });
|
||||
try {
|
||||
await new Promise<void>((resolve,reject)=>{
|
||||
const socket = new WebSocket(`ws://localhost:${server.port}/realtime/updates`, {headers:{cookie:"test-session=valid",origin:`http://localhost:${server.port}`}});
|
||||
const timer=setTimeout(()=>{socket.close();reject(Error("upgrade timed out"))},3000);
|
||||
socket.onopen=()=>{clearTimeout(timer);socket.close();resolve()};
|
||||
socket.onerror=()=>{clearTimeout(timer);reject(Error("upgrade failed"))};
|
||||
});
|
||||
} finally { server.stop(true); await app.shutdown(); }
|
||||
});
|
||||
Reference in new issue
Block a user