Defjs

Treat 304 as an HTTP status fault; keep the cache Map outside the operation call.

Revalidate with ETag and a 304

ok means 2xx, so a 304 arrives as an HTTP_STATUS fault. A redirect status is not an error body either, so leave error undeclared: the body is never read, the fault carries the status, and you return the cached product yourself.

See HTTP.

import {
  createClient,
  type DerivableClient,
  defineRequest,
  struct,
  type Infer,
  withEndpoint,
  withHTTPHandle,
} from "@defjs/core";

const Product = struct.object({
  sku: struct.string(),
  name: struct.string(),
  priceCents: struct.number(),
});

type Product = Infer<typeof Product>;

const readProduct = defineRequest({
  method: "GET",
  path: "/catalog/:sku",
  input: struct.object({
    sku: struct.string(),
    etag: struct.string().optional(),
  }),
  builder: (input) => ({
    path: { sku: input.sku },
    headers: { "If-None-Match": input.etag },
  }),
  output: Product,
});

function createReader(client: DerivableClient) {
  const cache = new Map<string, { etag: string; product: Product }>();

  return async (sku: string): Promise<Product> => {
    const cached = cache.get(sku);
    const [error, product, response] = await readProduct(client, {
      sku,
      etag: cached?.etag,
    });

    if (error?.code === "HTTP_STATUS" && error.status === 304 && cached) {
      return cached.product;
    }
    // This reader returns Product, so a non-304 fault leaves here by throwing.
    if (error) throw error;

    const etag = response.headers.get("etag");
    if (!etag) throw new Error("missing ETag");
    cache.set(sku, { etag, product });
    return product;
  };
}

const etag = '"v7"';
const client = createClient(
  withEndpoint("https://catalog.example.test"),
  withHTTPHandle(async (input, init) => {
    if (new Request(input, init).headers.get("if-none-match") === etag) {
      return new Response(null, { status: 304, headers: { etag } });
    }
    return Response.json(
      { sku: "SKU-1", name: "Flask", priceCents: 3200 },
      { status: 200, headers: { etag } },
    );
  }),
);
const read = createReader(client);
await read("SKU-1");
const product = await read("SKU-1");
console.log(product.name);
Flask

throw error after the 304 branch is this reader's boundary. The function still returns Promise<Product>; a non-304 fault is not returned as a Defjs tuple from that boundary.

Do not rewrite 304 into a fake 200 inside Core. Cache ownership stays in application code around the operation call.

Edit this page on GitHub
Released under the MIT License. · Copyright © 2026 MunMunMiao

On this page