Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a0270c6
fix(web): judge arguments, results and redirect targets by what they are
frenzzy Sep 2, 2026
8f59ebe
test(signals): isolate structural attribution controls
ryansolid Sep 2, 2026
2a6567d
fix(web): track in-place class mutations
nickshiro Sep 1, 2026
e90f776
chore(size): account for applied class snapshots
ryansolid Sep 2, 2026
e87cdcd
test(compiler): normalize TSRX virtual paths on Windows
ryansolid Sep 2, 2026
8e30fed
test(web): cover the carrier case, and make the tables prove the call…
frenzzy Sep 2, 2026
77ef69f
Merge pull request #3191 from nickshiro/fix/3188-in-place-class-mutation
ryansolid Sep 2, 2026
db73936
refactor(web): name the framing headers once, and drop three needless…
frenzzy Sep 2, 2026
b73635b
fix(web): preserve reusable bound event tuples
nickshiro Sep 1, 2026
1371998
fix(web): retain unchanged bound event listeners
ryansolid Sep 2, 2026
8d1a011
Merge pull request #3190 from nickshiro/fix/3186-reusable-event-handl…
ryansolid Sep 2, 2026
f4e490b
fix(web): judge arguments, results and redirect targets by what they are
frenzzy Sep 2, 2026
8f8116a
test(web): cover the carrier case, and make the tables prove the call…
frenzzy Sep 2, 2026
c1bc98e
refactor(web): name the framing headers once, and drop three needless…
frenzzy Sep 2, 2026
4799541
fix(web): harden server transport boundaries
ryansolid Sep 2, 2026
059d286
Merge contributor branch after audit rebase
ryansolid Sep 2, 2026
afed2e0
fix(web): keep transport helpers internal
ryansolid Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preserve-bound-event-tuples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Keep bound handler tuples reusable across non-delegated events by leaving the user-provided tuple unchanged when installing a listener.
17 changes: 17 additions & 0 deletions .changeset/server-function-guard-simplification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@solidjs/web": patch
---

Judge decoded arguments, guarded results and navigation targets by what they
are rather than by how they are spelled, and never forward a `Content-Length`
that describes a body the transport replaced.

Seven defects, five of them introduced by the guards added in #3168, #3170,
#3175 and #3176. Each fix removes a special case rather than adding one: the
argument walk stops for no prototype, the guard shell keeps only the flag that
reaches the wire, the scheme floor asks the URL parser instead of a regex, and
the event is awaited only when it is genuinely a promise.

Runtime-composed SSR responses now discard stale body-framing headers, and
late streaming redirects emit a client script only for relative or HTTP(S)
targets.
5 changes: 5 additions & 0 deletions .changeset/track-applied-classes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Update object-valued class bindings after in-place mutations by tracking a separate snapshot of the classes applied to each element.
21 changes: 15 additions & 6 deletions packages/compiler/__tests__/tsrx-typecheck-projection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ const path = require("path");
const ts = require("typescript");
const { projectTsrxForTypecheck } = require("..");

function sameFileName(left, right) {
const canonical = name => {
const resolved = ts.sys.resolvePath(name).replaceAll("\\", "/");
return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase();
};
return canonical(left) === canonical(right);
}

function typecheck(code) {
const filename = path.join(__dirname, "__virtual-tsrx-projection.tsx");
const repository = path.resolve(__dirname, "../../..");
Expand All @@ -23,10 +31,10 @@ function typecheck(code) {
};
const host = ts.createCompilerHost(options);
const getSourceFile = host.getSourceFile.bind(host);
host.fileExists = name => name === filename || ts.sys.fileExists(name);
host.readFile = name => (name === filename ? code : ts.sys.readFile(name));
host.fileExists = name => sameFileName(name, filename) || ts.sys.fileExists(name);
host.readFile = name => (sameFileName(name, filename) ? code : ts.sys.readFile(name));
host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) =>
name === filename
sameFileName(name, filename)
? ts.createSourceFile(name, code, languageVersion, true, ts.ScriptKind.TSX)
: getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile);
const program = ts.createProgram([filename], options, host);
Expand Down Expand Up @@ -61,14 +69,15 @@ function createLanguageService(code) {
getDefaultLibFileName: compilerOptions => ts.getDefaultLibFilePath(compilerOptions),
getScriptFileNames: () => [filename],
getScriptSnapshot: name => {
if (name === filename) return ts.ScriptSnapshot.fromString(code);
if (sameFileName(name, filename)) return ts.ScriptSnapshot.fromString(code);
const text = ts.sys.readFile(name);
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
},
getScriptVersion: () => "0",
fileExists: ts.sys.fileExists,
fileExists: name => sameFileName(name, filename) || ts.sys.fileExists(name),
readDirectory: ts.sys.readDirectory,
readFile: ts.sys.readFile
readFile: name => (sameFileName(name, filename) ? code : ts.sys.readFile(name)),
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames
};
return { filename, service: ts.createLanguageService(host) };
}
Expand Down
10 changes: 5 additions & 5 deletions packages/signals/tests/attribution-benchmark-eval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ afterEach(() => {
function arm(opts: Parameters<typeof DEV.attribution.enable>[0] = {}) {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
DEV!.attribution.enable({ log: false, ...opts });
// These benchmark-shaped scenarios evaluate structural attribution. Keep
// the independent wall-clock detector out: coverage and runner contention
// can legitimately push a fine-grained scope over its default 8ms budget.
DEV!.attribution.enable({ log: false, hotTime: false, ...opts });
const diagnostics: DiagnosticEvent[] = [];
DEV!.diagnostics.subscribe(e => diagnostics.push(e));
const reruns: RerunEvent[] = [];
Expand Down Expand Up @@ -227,10 +230,7 @@ describe("healthy fine-grained benchmark implementation (false-positive control)
});
flush();

// This is a structural false-positive control. Wall-clock attribution is
// deliberately outside its claim: coverage/contended CI can push an
// otherwise fine-grained row effect over the default 8ms budget.
const { diagnostics, reruns } = arm({ hotTime: false });
const { diagnostics, reruns } = arm();
setState(s => {
for (let i = 0; i < n; i += 10) s.rows[i].label += " !!!";
});
Expand Down
123 changes: 81 additions & 42 deletions packages/web/server-functions/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isResponseEnvelope,
isSafeError
} from "../../src/response.js";
import { COMPOSED_BODY_FRAMING, isHttpNavigationTarget } from "../../src/constants.js";
import { RequestContext, commitEventResponse, getRequestEvent } from "../../src/server.js";
import { encodeFlashCookie } from "./flash.js";
import {
Expand Down Expand Up @@ -1095,7 +1096,7 @@ function assertDecodeDepth(value) {
}

/**
* Strips own `__proto__` keys from a decoded argument graph, in place.
* Strips prototype-mutating keys from a decoded argument graph, in place.
*
* Both decode roads preserve the key faithfully — `JSON.parse` creates it
* as an ordinary own property and the codec round-trips it the same way —
Expand All @@ -1110,29 +1111,28 @@ function assertDecodeDepth(value) {
* The walk is iterative (the codec revives cyclic graphs, and depth is the
* attack input on the JSON road) with a visited set for cycles. It reaches
* plain objects and arrays, plus the values and keys of revived Maps and
* Sets; a value nested inside a revived class instance keeps its shape —
* the codec owns that value's construction, not this guard.
* Sets, and enumerable properties on revived non-plain objects. Containers
* keep their shape — the codec owns their construction, not this guard.
*/
function stripOwnProtoKeys(value) {
const UNSAFE_ARGUMENT_KEYS = ["__proto__", "constructor", "prototype"];

function stripUnsafeArgumentKeys(value) {
const stack = [value];
const seen = new Set();
while (stack.length) {
const v = stack.pop();
if (v === null || typeof v !== "object" || seen.has(v)) continue;
seen.add(v);
if (Array.isArray(v)) {
for (let i = 0; i < v.length; i++) stack.push(v[i]);
} else if (v instanceof Map) {
// Mutating in place never required a plain prototype. Strip every
// container, then walk both own metadata and collection contents.
for (const key of UNSAFE_ARGUMENT_KEYS) {
delete v[key];
}
for (const key of Object.keys(v)) stack.push(v[key]);
if (v instanceof Map) {
for (const [k, entry] of v) stack.push(k, entry);
} else if (v instanceof Set) {
for (const member of v) stack.push(member);
} else {
const proto = Object.getPrototypeOf(v);
if (proto !== Object.prototype && proto !== null) continue;
if (Object.prototype.hasOwnProperty.call(v, "__proto__")) {
delete v["__proto__"];
}
for (const key of Object.keys(v)) stack.push(v[key]);
}
}
return value;
Expand Down Expand Up @@ -1195,7 +1195,7 @@ async function parseArguments(request, url, scripted, codec) {
if (!Array.isArray(result)) {
throw new TypeError("Server function arguments must encode an array");
}
stripOwnProtoKeys(result);
stripUnsafeArgumentKeys(result);
for (const arg of result) {
parsed.push(arg);
}
Expand All @@ -1221,7 +1221,7 @@ async function parseArguments(request, url, scripted, codec) {
if (!Array.isArray(decoded)) {
throw new TypeError("Server function arguments must encode an array");
}
return stripOwnProtoKeys(decoded);
return stripUnsafeArgumentKeys(decoded);
}
if (decoded === undefined) {
// The decode switch fell through: the format tag — or its duplicate-
Expand Down Expand Up @@ -1427,7 +1427,7 @@ export function foldSetCookies(headers, setCookies) {
// way response headers may merge here: never `get`/`set` folding.
function mergeResponseHeaders(target, source) {
source.forEach((value, key) => {
if (key !== "set-cookie") target.append(key, value);
if (key !== "set-cookie" && !COMPOSED_BODY_FRAMING.has(key)) target.append(key, value);
});
if (source.getSetCookie) {
for (const cookie of source.getSetCookie()) target.append("Set-Cookie", cookie);
Expand Down Expand Up @@ -1500,12 +1500,8 @@ const BOUNDED_COMPOSED_HEADERS = [REDIRECT_HEADER, "Location", REVALIDATE_HEADER
// custom app scheme (deep links) is refused by default until that ruling
// gives it an opt-in. The decoder enforces the same floor independently
// (decodeRedirectHeaderValue), so a hostile peer cannot re-open the class
// against integrations either.
function refusedTargetScheme(target) {
// explicit scheme present and not http(s) → refused
const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.exec(target);
return match !== null && !/^https?:$/i.test(match[0]);
}
// against integrations either. The shared parser also guards late streaming
// SSR redirects before they become executable script.

// The transport half of redirect()'s and initWithRevalidate's invariants:
// composed-header BOUNDS (#3158 — an over-long header dies at a proxy
Expand Down Expand Up @@ -1542,7 +1538,7 @@ function enforceComposedHeaderInvariants(response) {
if (name === REVALIDATE_HEADER) continue;
// REDIRECT_HEADER rides as "<status> <target>"; Location is the target
const target = name === REDIRECT_HEADER ? value.slice(value.indexOf(" ") + 1) : value;
if (refusedTargetScheme(target)) {
if (!isHttpNavigationTarget(target)) {
return refuseComposedHeader(
response,
name,
Expand Down Expand Up @@ -1719,8 +1715,8 @@ function isFormPost(request) {
// protection, it was a bypass: a rejecting promise behind a getter or used
// as a Map key rode the wire unsanitized, was never torn down, and on the
// promise path took the process down with an unhandled rejection (#3176).
// Getters are now invoked once and materialized as data properties; Map
// keys are walked like values.
// Enumerable getters are now invoked once and materialized as data
// properties; Map keys are walked like values.
//
// `state.gate` (optional — serializeResponseStream threads it, #3125) hooks
// the two STREAMING channels into the response lifetime as they are walked:
Expand Down Expand Up @@ -1822,13 +1818,12 @@ export function guardFailures(value, state) {
// value needed no wrapping): only the rebuilt shell carries the
// data property — pass the original through and the codec would
// invoke the getter afresh, minting a new unguarded channel.
Object.defineProperty(
top.next,
items[i],
top.accessorRead === i
? { enumerable: true, configurable: true, writable: true, value: guarded }
: { ...top.descriptors[items[i]], value: guarded }
);
Object.defineProperty(top.next, items[i], {
value: guarded,
writable: true,
configurable: true,
enumerable: top.descriptors[items[i]].enumerable
});
top.changed = true;
}
top.i++;
Expand Down Expand Up @@ -2029,9 +2024,20 @@ function enterGuard(value, state) {
// rebuild. Getter-backed accessors are materialized by the driver (#3176):
// invoked once there, rewritten as data properties on this shell.
const descriptors = Object.getOwnPropertyDescriptors(value);
// The shell is scratch the codec reads once and the author never holds, so
// make its fresh descriptors rewritable in place. This preserves the one
// serialization flag (`enumerable`) while frozen slots can be replaced
// (#3196, #3198), and keeps an authored own `__proto__` descriptor as data
// instead of interpreting its name while copying through an ordinary object.
for (const key of Object.keys(descriptors)) {
descriptors[key].configurable = true;
if ("value" in descriptors[key]) descriptors[key].writable = true;
}
const next = Object.create(prototype, descriptors);
state.seen.set(value, next);
return new Frame(OBJECT, value, next, Object.keys(descriptors), descriptors);
// The codec reads enumerable string properties; hidden accessors must stay
// hidden without being invoked merely because another slot needs guarding.
return new Frame(OBJECT, value, next, Object.keys(value), descriptors);
}

/** A rebuild stands if anything below changed, or if a cycle already took it. */
Expand Down Expand Up @@ -2510,7 +2516,19 @@ function forbiddenResponse() {
headers: { "Cache-Control": "no-store" }
})
);
} /**
}

function nativePromise(value) {
if (value instanceof Promise) return value;
try {
// `instanceof` is realm-local. The intrinsic brand check accepts a
// genuine Promise from another realm without adopting arbitrary thenables.
if (Object.prototype.toString.call(value) === "[object Promise]")
return Promise.prototype.then.call(value, value => value);
} catch {}
}

/**
* Web-standard HTTP handler for server function calls: resolves the
* function id from the request, enforces the method allowlist (POST always
* dispatches; GET and HEAD dispatch only to functions that declared `GET`,
Expand Down Expand Up @@ -2784,14 +2802,35 @@ export async function handleServerFunctionRequest(request, options = {}) {
}
}

let event = options.createEvent ? options.createEvent(request) : { request, locals: {} };
// An async createEvent is out of contract (the type is synchronous), but
// handing a pending Promise downstream as the event is the worst failure
// available: the function runs, the caller sees 200, and every header the
// integration wrote on the real event's stub silently vanishes (#3170).
// Awaiting is strictly better than refusing — the resolved value IS the
// event the integration meant.
if (typeof (event as any)?.then === "function") event = await event;
// handing a pending Promise downstream is the worst failure available: the
// function runs, the caller sees 200, and every header the integration
// wrote on the stub vanishes (#3170). So it is awaited — but only when it
// is genuinely a Promise. The event is a datum an integration handed back,
// not something the runtime asked to be async, and awaiting anything
// wearing a `then` parked the request forever on a lazy-locals proxy and
// starved the event loop on a self-resolving one (#3199). A failure here
// is answered rather than thrown: no event exists yet, so there is no stub
// to fold and nothing downstream can report it.
let event;
try {
event = options.createEvent ? options.createEvent(request) : { request, locals: {} };
const promised = nativePromise(event);
if (promised) event = await promised;
} catch (error) {
const safe = sanitizeServerError(error);
const message = safe instanceof Error ? safe.message : String(safe);
const headers = new Headers();
headers.set(ERROR_HEADER, boundedErrorHeaderValue(message));
// A scripted failure is still runtime protocol, so encode it like a
// function throw rather than making the client misclassify it as an
// untagged peer 500. Plain HTTP keeps the ordinary bodiless production
// response.
const response = scripted
? encodeResult(safe, headers, 500, codec, request.signal)
: new Response(DEV ? message : null, { status: 500 });
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
}
// Once an event exists, its response stub folds onto EVERY exit — the
// refusals below included (#3159). A refusal that returned directly
// dropped the stub silently: an integration's Set-Cookie written in
Expand Down
Loading
Loading