Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .changeset/32565-compression-vary-304-hosted-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-hono-server': patch
'@objectstack/hono': patch
---

docs(plugin-hono-server, hono): say which `304` answers carry `Vary: Accept-Encoding`, and that the hosted door leaves compression to the platform or CDN (#32565, #32536)

Clause-②: no
Metadata-protocol: none

No runtime behaviour changes; the READMEs both packages ship now say what the server does.

- `@objectstack/plugin-hono-server`: the README said every `304` for a compressible response carries `Vary: Accept-Encoding`. The server decides from the answer it sends, not from the `200` the `304` stands for, so a `304` carries that `Vary` only when it carries a compressible `Content-Type` of its own. A dispatcher answer written with `res.json()` does; a bare `304` with only `ETag` and `Cache-Control` (the Console's static files answer one) does not. A cache refreshing a stored answer from a `304` keeps the stored `Vary`, so no cached answer is wrong. A new test pins both shapes.
- `@objectstack/hono`: `createHonoApp`, the hosted door for edge runtimes, does not compress responses and sends no `Vary: Accept-Encoding`. Compression there is the job of the platform or CDN in front of the app, as ruled on #32155 (ruling Y′). The README now says so, and asks operators to check that their host compresses and keeps the `ETag` unchanged.
16 changes: 16 additions & 0 deletions content/docs/deployment/production-readiness.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ those responses are **per-caller**, and the rules below are what keep that safe.
set, every caller is served every object, including objects they may not
read, which the mask otherwise answers as objects that do not exist.

## Response compression on each door

Who compresses a response depends on the door that serves it:

| Door | Who compresses |
| :--- | :--- |
| Self-hosted server: `@objectstack/plugin-hono-server`, what `os dev` and `os start` run | **The server**, by default: brotli or gzip for compressible responses of 1 KB or more. See [Response Compression](/docs/protocol/kernel/http-protocol#response-compression). |
| Hosted door: `createHonoApp` from `@objectstack/hono` on Cloudflare Workers, Deno Deploy, Bun or Vercel Edge | **The platform or CDN in front of it.** The application does not compress there: it sends no `Content-Encoding` and no `Vary: Accept-Encoding`. |

On the hosted door, check that the platform compresses JSON, JavaScript and CSS
responses, and turn it on there if it does not (a runtime you serve yourself
may not compress at all: put a proxy or CDN in front of it), rather than adding
compression to the app. Either way, keep the `ETag` unchanged when a layer
compresses: saves send it back as `If-Match` and the server compares it
strongly, so a layer that weakens it to `W/` makes those saves fail.

## What's NOT in the runtime (yet)

- **OTel context propagation.** We export `parseTraceparent` /
Expand Down
10 changes: 9 additions & 1 deletion content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1185,9 +1185,17 @@ Vary: Accept-Encoding
- Responses that already carry a `Content-Encoding`, `206 Partial Content`,
`HEAD`, and responses marked `Cache-Control: no-transform` are sent unchanged.
- Every response the server could encode carries `Vary: Accept-Encoding`. So do
the `HEAD`, `206` and `304` answers for such a response.
the `HEAD` and `206` answers for such a response.
- A `304 Not Modified` is never encoded. It carries `Vary: Accept-Encoding` only
when it carries a compressible `Content-Type` of its own, because the server
judges the answer it sends, not the `200` the `304` stands for. A bare `304`
has no `Vary`, and a cache refreshing its stored copy from it keeps the
stored `Vary`.
- The `ETag` is the same on encoded and unencoded answers. A save sends it back
as `If-Match`, and the server compares it strongly.
- The hosted door (`createHonoApp` from `@objectstack/hono`, on edge runtimes)
does not compress: the platform or CDN in front of it does. See
[Production readiness](/docs/deployment/production-readiness#response-compression-on-each-door).

## Rate Limiting

Expand Down
18 changes: 18 additions & 0 deletions packages/adapters/hono/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ app.use('/api/*', objectStackMiddleware(kernel));
- Drivers differ by runtime: use [`@objectstack/driver-sql`](../../drivers/driver-sql) on Node. (Edge/multi-tenant turso driver ships in ObjectOS Cloud.)
- Persist no long-lived state in module scope beyond the `kernel` instance.

## Response compression

`createHonoApp` does not compress responses. It sends no `Content-Encoding` and adds no
`Vary: Accept-Encoding`. On this door, compression is the job of the platform or CDN in
front of the app, and the application does not compress there. The edge runtimes this
adapter targets do not all offer `node:zlib`, and the edge already does this work.

- Check that your host compresses JSON, JavaScript and CSS responses. If it does not,
turn it on there, not in the app. A runtime you serve yourself may not compress at
all; put a proxy or CDN in front of it.
- Keep the `ETag` unchanged when the host compresses. Saves send it back as `If-Match`
and the server compares it strongly, so a host that rewrites it to a weak `W/` tag
makes those saves fail their precondition.

The self-hosted server, [`@objectstack/plugin-hono-server`](../../plugins/plugin-hono-server)
(what `os dev` and `os start` run), does compress responses itself; its README says
which ones.

## When to use

- ✅ Edge, serverless, and multi-runtime deployments.
Expand Down
8 changes: 6 additions & 2 deletions packages/plugins/plugin-hono-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,12 @@ whichever route serves them. These responses are never encoded:
- **Responses marked `Cache-Control: no-transform`.**

Every response the server could encode carries `Vary: Accept-Encoding`, whatever the
client sent. So do the `HEAD`, `206` and `304` answers for such a response, although
they are never encoded themselves. The `ETag` is passed through unchanged. Saves send it back as `If-Match`
client sent. So do the `HEAD` and `206` answers for such a response, although they are
never encoded themselves. A `304 Not Modified` is never encoded either, and it carries
`Vary: Accept-Encoding` only when it carries a compressible `Content-Type` of its own:
the server judges the answer it sends, not the `200` the `304` stands for. A bare `304`
(the Console's static files answer one) has no `Vary`; a cache refreshing its stored
copy from it keeps the stored `Vary`. The `ETag` is passed through unchanged. Saves send it back as `If-Match`
and the server compares it strongly, so a weakened `W/` tag would fail those saves.

A reverse proxy or CDN in front of the server sees `Content-Encoding` and does not
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,41 @@ describe('HonoHttpServer response compression', () => {
expect(head.headers['vary']).toMatch(/accept-encoding/i);
});

// A `304` is never encoded, and it carries `Vary: Accept-Encoding` exactly
// when it carries a compressible `Content-Type`: the middleware judges the
// answer it is handed and cannot see the `200` the `304` stands for.
it('gives a 304 Vary: Accept-Encoding only when it carries a compressible Content-Type, and never encodes it', async () => {
const etag = '"v1"';
const port = await boot((s) => {
// The Console's stable-file shape: `ETag` and `Cache-Control`, no `Content-Type`.
s.getRawApp().get('/bare-304', () => new Response(null, {
status: 304,
headers: { etag, 'cache-control': 'no-cache' },
}));
// The dispatcher's shape (the translation bundle): `res.json()` of no body.
s.get('/json-304', (_req, res) => {
res.status(304).header('ETag', etag);
res.json(undefined);
});
});

const bare = await get(port, '/bare-304', { 'accept-encoding': 'gzip, br', 'if-none-match': etag });
expect(bare.status).toBe(304);
expect(bare.headers['content-type']).toBeUndefined();
expect(bare.headers['vary']).toBeUndefined();
expect(bare.headers['content-encoding']).toBeUndefined();
expect(bare.headers['etag']).toBe(etag);
expect(bare.body.byteLength).toBe(0);

const typed = await get(port, '/json-304', { 'accept-encoding': 'gzip, br', 'if-none-match': etag });
expect(typed.status).toBe(304);
expect(typed.headers['content-type']).toMatch(/^application\/json/);
expect(typed.headers['vary']).toMatch(/accept-encoding/i);
expect(typed.headers['content-encoding']).toBeUndefined();
expect(typed.headers['etag']).toBe(etag);
expect(typed.body.byteLength).toBe(0);
});

it('survives a client that leaves in the middle of an encoded answer', async () => {
// Text that compresses, but not to nothing: the encoder still has
// output pending when the client goes away.
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-hono-server/src/response-compression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,14 @@
*
* `Vary: Accept-Encoding` goes on every response this could encode, whatever
* the request accepted and whatever the body's size, so a shared cache never
* hands one client's encoding to another. It also goes on the `HEAD`, `206`
* and `304` answers for such a response, which are never encoded themselves:
* a `HEAD` carries the header fields its `GET` would, and a cache refreshes a
* stored answer's headers from a `304`. The `ETag` is passed through
* hands one client's encoding to another. It also goes on the `HEAD` and `206`
* answers for such a response, which are never encoded themselves: a `HEAD`
* carries the header fields its `GET` would. The rule reads the answer it is
* handed, never the route's `200`, so a `304` carries it only when the `304`
* itself carries a compressible `Content-Type` (the dispatcher's `res.json()`
* answers do; the Console's bare stable-file `304` does not). A cache that
* refreshes a stored answer from a `304` keeps the stored `Vary`, so a `304`
* without one loses nothing. The `ETag` is passed through
* unchanged: the metadata and record doors compare an `If-Match` token against
* it strongly, so rewriting it to a weak `W/` form on encoded answers would make
* every save that pins the version it just read fail its precondition.
Expand Down Expand Up @@ -219,8 +223,9 @@ export async function compressResponse(
if (vary !== '*' && !(vary && VARY_ACCEPT_ENCODING.test(vary))) {
c.header('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');
}
// A `HEAD`, a partial answer and a revalidation carry the `Vary` of the
// full answer they stand for, and are never encoded themselves.
// A `HEAD`, a partial answer and a revalidation are never encoded
// themselves. A `304` reaches this line only when it carries a
// compressible `Content-Type` of its own: a bare `304` gets no `Vary`.
if (c.req.method === 'HEAD' || c.res.status === 206 || c.res.status === 304) return;

const length = declaredLength(c.res.headers);
Expand Down
Loading