|
| 1 | +/** |
| 2 | + * What the transport does with a response the runtime did not write (#3087). |
| 3 | + * |
| 4 | + * Its own responses are recognisable: they carry the error header, or the |
| 5 | + * body format every encoding path stamps — a void result included. Anything |
| 6 | + * else at 400 and up is the peer refusing, and decoding one yields nothing, |
| 7 | + * which used to resolve the call to `undefined`. These pin the refusals that |
| 8 | + * now fail, the value-shaped statuses that must not, and the limit of what a |
| 9 | + * status can tell you. |
| 10 | + * |
| 11 | + * One runtime-produced shape is caught along with them: a verbatim |
| 12 | + * `X-Content-Raw` response at a non-2xx status, whose body no call site |
| 13 | + * without an integration could read anyway. An integration is unaffected — |
| 14 | + * its `responseHandler` claims the response before any of this. |
| 15 | + * |
| 16 | + * Like the extension specs, these run against the built bundles |
| 17 | + * (server-functions/dist/*, wired up in vite.config.server.mjs). |
| 18 | + */ |
| 19 | +import { AsyncLocalStorage } from "node:async_hooks"; |
| 20 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; |
| 21 | +import { redirect, respond } from "@solidjs/web"; |
| 22 | +import { |
| 23 | + handleServerFunctionRequest, |
| 24 | + registerServerFunction |
| 25 | +} from "@solidjs/web/server-functions/server"; |
| 26 | +import { |
| 27 | + configureServerFunctionsClient, |
| 28 | + createServerReference |
| 29 | +} from "@solidjs/web/server-functions/client"; |
| 30 | + |
| 31 | +const RequestContext = Symbol.for("solid.RequestContext"); |
| 32 | + |
| 33 | +beforeAll(() => { |
| 34 | + (globalThis as any)[RequestContext] = new AsyncLocalStorage(); |
| 35 | +}); |
| 36 | + |
| 37 | +afterAll(() => { |
| 38 | + delete (globalThis as any)[RequestContext]; |
| 39 | +}); |
| 40 | + |
| 41 | +/** |
| 42 | + * The transport's fetch. `answer` replaces the handler with a response from |
| 43 | + * somewhere else; `rewrite` sends the call to a different address. |
| 44 | + */ |
| 45 | +function connectTransport({ |
| 46 | + answer, |
| 47 | + rewrite |
| 48 | +}: { answer?: () => Response; rewrite?: (address: string) => string } = {}) { |
| 49 | + const original = globalThis.fetch; |
| 50 | + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { |
| 51 | + if (answer) return Promise.resolve(answer()); |
| 52 | + const address = input instanceof Request ? input.url : input.toString(); |
| 53 | + const request = new Request( |
| 54 | + new URL(rewrite ? rewrite(address) : address, "http://localhost"), |
| 55 | + input instanceof Request ? input : init |
| 56 | + ); |
| 57 | + request.headers.set("Sec-Fetch-Site", "same-origin"); |
| 58 | + return handleServerFunctionRequest(request); |
| 59 | + }) as typeof fetch; |
| 60 | + return () => { |
| 61 | + globalThis.fetch = original; |
| 62 | + }; |
| 63 | +} |
| 64 | + |
| 65 | +const foreign = (status: number, body: BodyInit | null, type?: string) => () => |
| 66 | + new Response(body, { status, headers: type ? { "content-type": type } : undefined }); |
| 67 | + |
| 68 | +describe("server-function transport failures (#3087)", () => { |
| 69 | + it("fails the call when the handler refuses it", async () => { |
| 70 | + const restore = connectTransport(); |
| 71 | + try { |
| 72 | + // a client that outlived the build registering its function |
| 73 | + await expect(createServerReference("fail-never-registered")()).rejects.toMatchObject({ |
| 74 | + status: 404 |
| 75 | + }); |
| 76 | + } finally { |
| 77 | + restore(); |
| 78 | + } |
| 79 | + }); |
| 80 | + |
| 81 | + it("fails the call when the handler rejects the request itself", async () => { |
| 82 | + registerServerFunction("fail-args", async () => "ok"); |
| 83 | + // something under `args` that is not an argument array: a 400 for every |
| 84 | + // caller of that url |
| 85 | + const restore = connectTransport({ rewrite: address => `${address}?args=nope` }); |
| 86 | + try { |
| 87 | + await expect(createServerReference("fail-args")()).rejects.toMatchObject({ status: 400 }); |
| 88 | + } finally { |
| 89 | + restore(); |
| 90 | + } |
| 91 | + }); |
| 92 | + |
| 93 | + it("fails the call on a response nothing in the runtime wrote", async () => { |
| 94 | + registerServerFunction("fail-foreign", async () => "ok"); |
| 95 | + for (const answer of [ |
| 96 | + foreign(404, "<h1>Not found</h1>", "text/html"), |
| 97 | + foreign(401, "<html>login</html>", "text/html"), |
| 98 | + foreign(403, null) |
| 99 | + ]) { |
| 100 | + const restore = connectTransport({ answer }); |
| 101 | + try { |
| 102 | + await expect(createServerReference("fail-foreign")()).rejects.toMatchObject({ |
| 103 | + status: answer().status |
| 104 | + }); |
| 105 | + } finally { |
| 106 | + restore(); |
| 107 | + } |
| 108 | + } |
| 109 | + }); |
| 110 | + |
| 111 | + it("fails one carrying integration metadata of its own", async () => { |
| 112 | + registerServerFunction("fail-interstitial", async () => "ok"); |
| 113 | + // an SSO interstitial answers 403 with a Location, and a gateway can |
| 114 | + // answer 404 with anything — neither is the runtime's control flow, so |
| 115 | + // neither may take the passthrough that control flow uses |
| 116 | + for (const [status, headers] of [ |
| 117 | + [403, { "content-type": "text/html", Location: "https://sso.example/login" }], |
| 118 | + [404, { "X-Revalidate": "stories" }] |
| 119 | + ] as const) { |
| 120 | + const restore = connectTransport({ answer: () => new Response(null, { status, headers }) }); |
| 121 | + try { |
| 122 | + await expect(createServerReference("fail-interstitial")()).rejects.toMatchObject({ |
| 123 | + status |
| 124 | + }); |
| 125 | + } finally { |
| 126 | + restore(); |
| 127 | + } |
| 128 | + } |
| 129 | + }); |
| 130 | + |
| 131 | + it("fails with an Error even when the foreign body sniffs as a known encoding", async () => { |
| 132 | + registerServerFunction("fail-sniffed", async () => "ok"); |
| 133 | + // content-type sniffing would decode this as URLSearchParams; a refusal's |
| 134 | + // body is not a payload, and a caller reads `.status`, not a form |
| 135 | + const restore = connectTransport({ |
| 136 | + answer: foreign(403, "reason=blocked", "application/x-www-form-urlencoded") |
| 137 | + }); |
| 138 | + try { |
| 139 | + await expect(createServerReference("fail-sniffed")()).rejects.toMatchObject({ status: 403 }); |
| 140 | + await expect(createServerReference("fail-sniffed")()).rejects.toBeInstanceOf(Error); |
| 141 | + } finally { |
| 142 | + restore(); |
| 143 | + } |
| 144 | + }); |
| 145 | + |
| 146 | + it("resolves a value-shaped status the function itself produced", async () => { |
| 147 | + registerServerFunction("fail-validated", async () => |
| 148 | + respond({ field: "required" }, { status: 400 }) |
| 149 | + ); |
| 150 | + registerServerFunction("fail-void", async () => respond(undefined, { status: 400 })); |
| 151 | + registerServerFunction("fail-empty", async () => new Response(null, { status: 404 })); |
| 152 | + const restore = connectTransport(); |
| 153 | + try { |
| 154 | + expect(await createServerReference("fail-validated")()).toEqual({ field: "required" }); |
| 155 | + // nothing, with a status on it — both spellings answer the same way |
| 156 | + expect(await createServerReference("fail-void")()).toBeUndefined(); |
| 157 | + expect(await createServerReference("fail-empty")()).toBeNull(); |
| 158 | + } finally { |
| 159 | + restore(); |
| 160 | + } |
| 161 | + }); |
| 162 | + |
| 163 | + it("leaves the runtime's own control flow on its passthrough", async () => { |
| 164 | + registerServerFunction("fail-redirect", async () => { |
| 165 | + throw redirect("/login"); |
| 166 | + }); |
| 167 | + const restore = connectTransport(); |
| 168 | + try { |
| 169 | + const response = await createServerReference("fail-redirect")(); |
| 170 | + expect(response).toBeInstanceOf(Response); |
| 171 | + expect((response as Response).headers.get("Location")).toBe("/login"); |
| 172 | + } finally { |
| 173 | + restore(); |
| 174 | + } |
| 175 | + }); |
| 176 | + |
| 177 | + it("cannot judge a 2xx, and does not try", async () => { |
| 178 | + registerServerFunction("fail-spa", async () => "ok"); |
| 179 | + // a login page or an SPA index served at 200 is indistinguishable from a |
| 180 | + // void result by header alone; the status is all this rule reads |
| 181 | + const restore = connectTransport({ answer: foreign(200, "<html>login</html>", "text/html") }); |
| 182 | + try { |
| 183 | + expect(await createServerReference("fail-spa")()).toBeUndefined(); |
| 184 | + } finally { |
| 185 | + restore(); |
| 186 | + } |
| 187 | + }); |
| 188 | + |
| 189 | + it("fails a verbatim passthrough that carries a refusal's status", async () => { |
| 190 | + registerServerFunction( |
| 191 | + "fail-raw", |
| 192 | + async () => new Response("nope", { status: 404, headers: { "X-Content-Raw": "1" } }) |
| 193 | + ); |
| 194 | + registerServerFunction( |
| 195 | + "fail-raw-ok", |
| 196 | + async () => new Response("here", { status: 200, headers: { "X-Content-Raw": "1" } }) |
| 197 | + ); |
| 198 | + const restore = connectTransport(); |
| 199 | + try { |
| 200 | + // a raw body is for whoever claims the response; a call site with no |
| 201 | + // integration could not read it at either status, and a status beats |
| 202 | + // minting `undefined` |
| 203 | + await expect(createServerReference("fail-raw")()).rejects.toMatchObject({ status: 404 }); |
| 204 | + expect(await createServerReference("fail-raw-ok")()).toBeUndefined(); |
| 205 | + } finally { |
| 206 | + restore(); |
| 207 | + } |
| 208 | + }); |
| 209 | + |
| 210 | + it("leaves the response to an integration that claims it", async () => { |
| 211 | + registerServerFunction( |
| 212 | + "fail-claimed", |
| 213 | + async () => new Response("frame", { status: 404, headers: { "X-Content-Raw": "1" } }) |
| 214 | + ); |
| 215 | + const restore = connectTransport(); |
| 216 | + configureServerFunctionsClient({ |
| 217 | + responseHandler: { handle: (response: Response) => response.status } |
| 218 | + }); |
| 219 | + try { |
| 220 | + // the seam runs before the check, which is what keeps server |
| 221 | + // components free to answer with a status of their own |
| 222 | + expect(await createServerReference("fail-claimed")()).toBe(404); |
| 223 | + } finally { |
| 224 | + configureServerFunctionsClient({ responseHandler: null as any }); |
| 225 | + restore(); |
| 226 | + } |
| 227 | + }); |
| 228 | + |
| 229 | + it("leaves an ordinary call alone", async () => { |
| 230 | + registerServerFunction("fail-plain", async (word: string) => word.toUpperCase()); |
| 231 | + const restore = connectTransport(); |
| 232 | + try { |
| 233 | + expect(await createServerReference("fail-plain")("solid")).toBe("SOLID"); |
| 234 | + } finally { |
| 235 | + restore(); |
| 236 | + } |
| 237 | + }); |
| 238 | +}); |
0 commit comments