How to handle redirects without setting window.location.href
?
Right now, there’s a pattern of doing:
sendHTTPRequest("POST", "/login/", {})
.then((_) => {
window.location.href = "/";
})
.catch((err) => { console.error(err); });
Isn’t this something that the server can do? In response, why not issue a redirect?

Screenshot of the redirect chain from /login. The POST request gets a 303 (See Other) redirect to /home. The browser then makes a GET request to /home, which results in a 304 (Not Modified). Why doesn’t the browser navigate me to /home?
Installing
http-status-codes
for more legible status codes.
This issue does not happen when I issue a GET
request to /logout
by
clicking on a <a href="/logout">Log Out</a>
. The server responds with
a 303 (See Other)
to /browse
, and the browser loads /browse
in the
tab. If I do the same with fetch('/logout', { method: 'GET' })
instead, then the browser does not navigate the page to /browse
despite the /browse
response being observable in the Network tab. The
consensus online is that fetch
responses do not lead to automatic
browser navigation.
Re: fetch
vs. XMLHttpRequest
. The latter is the older way of making
HTTP requests from JavaScript without leaving the page; it is based on
events. fetch
is built around promises, which are considered the
preferred way of doing asynchronous operations. fetch
and
XMLHttpRequest
are implemented by browser vendors. On top of these, numerous libraries exist, e.g., Fetch polyfill
,
isomorphic-fetch
, axios
, jQuery
, etc., to fill various gaps, e.g.,
different syntax, varying browser support, etc.
Typing the Client/Server Interface
One source of bugs is the server and the client being out of sync w.r.t.
the shape of the data being exchanged. Granted, I have both the server
and the client in the same repo, how can I avoid mismatches in data?
Is
there a library or a standard way of creating an interface for my api
endpoints? :
typescript
floats tRPC
, GraphQL
, OpenAPI
, and some others, with tRPC
coming
in first and GraphQL
as the incumbent.
GitHub Copilot can help out here with quick intros on what tRPC
and
GraphQL
entail. With GraphQL
, the client can strongly type the API,
define the data payloads, fetch all the data in one request, query for
supported types, and subscribe to real-time updates. GraphQL
is
intended to be an improvement over REST
. tRPC
is designed with
TypeScript in mind. Advantages of tRPC
include no schema syncing as
the input/output is inferred directly from function signatures, and can
work with GraphQL (or any other data fetching method). In the case of my
app, using tRPC
involves replacing Express
routes with tRPC
procedures. Trying tRPC
!
Impressed by Copilot. Definitely saved me time on this one.
What’s the use case for tRPC
for endpoints that return a static page
in response to a GET
request? Copilot says that serving HTML files
should be left to traditional web frameworks like Express
as those
have built-in support for SSR of HTML.
Adding tRPC endpoints that don’t need
authentication
was mostly mechanical. However, there were a few gotchas. Some
methods/props on a Mongoose
document (e.g., the Document
interface)
cannot be shared between the server and the client, and so we need to
define a safe interface that can be inferred by tRPC
and availed on
the client side. We also encountered a
challenge in typing the inputs, given that the app relies on Mongoose
validation that occurs after tRPC
has seen the inputs. Ended up adding
a passthrough input parser, e.g.,
export const authRouter = router({
registerUser: publicProcedure
.input((params: unknown) => params as RegisterUserAndPasswordParams)
.mutation(({ input }) => {
return registerUserAndPassword(input);
}),
});
The docs are not explicit about
this because not validating is considered unsafe, and the tRPC
maintainers don’t want to advocate for it. With the base knowledge from
non-auth endpoints and reading the docs several times,
adding
auth-dependent
endpoints
integrated nicely with Express
’s session management.
Augmenting Server Objects Client Side
For example, the server transmits JSON data of the ICardRaw
shape. Is
it possible to add helper methods in the client for ease of use? suggests that we can do:
// server-side-card.ts
export class Card {}
// client-side-card.ts
import { Card } from '../../server-side-card.js';
declare module '../../server-side-card.js' {
interface Card {
get formattedTags(): string[];
}
}
Card.prototype.formattedTags = function() {
return tags.split(' ').filter(Boolean).map(t => `#${t}`);
}
// consumer.ts
import { Card } from '../../server-side-card.js';
import 'client-side-card.js';
let c: Card;
console.log(c.formattedTags);
However, we have type PrivateCardResult = RouterOutput['fetchCard'][0]
in trpc.ts
. While we can extend the type
,
it doesn’t seem like we can add dynamic properties of the form:
type Foo = { s: string };
interface FooDeluxe extends Foo {
emphasized: () => string;
}
// 'FooDeluxe' only refers to a type, but is being used as a value here.ts(2693)
FooDeluxe.prototype.emphasized = function() {
return this.s.toUpperCase();
}
Foregoing tRPC
’s typed procedures is not worth it.
Validating Inputs
We use zod
(vouched for by tRPC
) to parse/validate the input on the
server. The “parse, don’t validate”
mantra resonates as something to apply to programming projects: instead
of validating input, parse it such that incorrect state is impossible to
represent.
zod
and tRPC
work especially well:
const fetchCardParamsValidator = z.object({
cardID: z.string().refine(isMongoId, { message: "Invalid card ID" }),
});
const inAppRouter = router({
fetchCard: authedProcedure
.input(fetchCardParamsValidator)
.query(({ input, ctx }) => {
return CardsDB.read({ ...input, userIDInApp: ctx.user.userIDInApp });
}),
});
Executing fetchCard({cardID: { $ne: "000000000000000000000000" }})
leads to a TRPCError
caused by a ZodError
. At the input stage, we
did not just validate that input
is of the correct shape, we discarded
input
s that were not of the correct shape. CardsDB.read
no longer
needs to worry that cardID
could be corrupted.
Typical Express middleware falls short of
the “parse, don’t validate” approach because the type information is not
inferrable from the request
object. For this, we fallback to assuming
that the validation middleware was applied, and it’s safe to trust
request.body
.
Cross-Site Request Forgery (CSRF) Prevention
In a CSRF attack, the attacker wants the victim to perform
state-changing requests unknowingly. Suppose Alice is logged into
bank.com
in her browser. To send money to Bob, the request is of the
form GET http://bank.com/transfer.do?acct=BOB&amount=100 HTTP/1.1
.
Maria, the attacker, send Alice an email with an invisible picture:
<img src="http://bank.com/transfer.do?acct=MARIA&amount=100000" width="0" height="0" border="0">
. When Alice opens the email, the
browser will try loading the image, thereby submitting the transfer
request to bank.com
, without Alice’s knowledge.
Token-based mitigation involves the server setting a CSRF token, which the client then echoes back. The server rejects the request if the echoed token is not valid. Because CSRF tokens are ephemeral, the chance that the token will still be valid when an attacker attempts a CSRF attack is reduced. Session-based tokens offer a good compromise between security and usability. Request-based tokens have usability concerns, e.g., breaking when the user uses the back button.
The lusca
package provides CSRF protection for Express apps. lusca
docs were
lacking as they showed how to configure CSRF protection on the server
but not on the client, leaving me with 403
errors to handle.
One technique is to use <form>
tags:
<form action="/transfer.do" method="post">
<input type="hidden" name="_csrf" value="OWY4NmQwODE4ODRjN2Q2NTlhMmZlYWEwYzU1YWQwMTVhM2JmNGYxYjJiMGI4MjJjZDE1ZDZMGYwMGEwOA==">
[...]
</form>
. This approach works well for forms
rendered through response.render
.
What about for tRPC
endpoints? A breakpoint in
node_modules/lusca/lib/csrf.js
shows that the CSRF token is looked for
in either req.body["_csrf"]
or req.headers["x-csrf-token"]
. tRPC
sends payloads of the form:
{
"0": {
"_id": "6682027e7c0aca290ed4ab4c",
"title": "Modified title",
}
}
… where 0
is a tRPC
internal; the client code for the above call
is something like trpc.updateCard.mutate({_id, title})
. tRPC
supports computing custom headers per request .
By setting the CSRF token in a <meta>
element, and then reading that
before issuing the tRPC
call, we’re able to have tRPC
calls succeed.
What about testing? The client-side tests uses a dev server , which doesn’t come with CSRF enforcement. The server-side uses an actual server instance, and it seems that each state-mutating test would need to pass a CSRF token. Maybe we can have a few tests that specifically check CSRF protections, and then disable CSRF for the rest of the tests?
The
304 Not Modified
response is sent when there is no need to retransmit the requested resources. In this case, the page at/home
has not changed, and the browser can use the cached version. If the content had changed, then the browser would have requested the resource from the server, resulting in a 200 response.