From a373155e2844ce3d566fad199d797ccfda34650c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:43:03 +0000 Subject: [PATCH 1/5] fix(runtime): the translations bundle carries an ETag and Cache-Control: private, no-cache, and a matching If-None-Match is answered 304 GET /i18n/translations/:locale (and its ?locale= spelling) served the whole locale bundle on every console open with no validator, so the browser could not revalidate it. The answer now carries a weak ETag over the bytes it serves and `Cache-Control: private, no-cache`; a request whose If-None-Match names that representation is answered 304 with no body. Claude-Session: https://claude.ai/code/session_01HFAuCZPty5SsvseoPRgukQ Co-authored-by: Claude --- packages/runtime/src/domains/i18n.ts | 89 +++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts index 48265ff9eac..bb41d094d8c 100644 --- a/packages/runtime/src/domains/i18n.ts +++ b/packages/runtime/src/domains/i18n.ts @@ -22,8 +22,14 @@ * the same floor as the metadata it translates. A client renders its sign-in * page from its own built-in strings and reads this domain once it holds a * session. + * + * The translation bundle is the one face a browser revalidates: it carries an + * `ETag` and `Cache-Control: private, no-cache`, and a matching + * `If-None-Match` is answered `304` with no body (#25785, see + * {@link translationsAnswer}). */ +import { createHash } from 'node:crypto'; import { resolveLocale, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, @@ -58,6 +64,85 @@ function readQueryLocale(deps: DomainHandlerDeps, query: any): { locale: any } | return { locale: raw[0] }; } +/** + * [#25785] The bundle's cache directive. `no-cache` lets the browser store the + * bundle but makes it ask again on every use, so an unchanged bundle costs a + * `304` and a changed one (an authored translation, a package installed) is + * served the moment it changes; a TTL would pin the old strings until it ran + * out. `private`, because the bundle is served to a signed-in caller only (see + * the floor in {@link handleI18nRequest}) and names the application's own + * objects: no shared cache may keep it. + */ +const TRANSLATIONS_CACHE_CONTROL = 'private, no-cache'; + +/** + * One request header, read from either shape a transport hands in: a Fetch + * `Headers` (the `@objectstack/hono` catch-all hands its raw `Request`) or a + * plain record (`plugin-hono-server`'s `IHttpRequest`). `Headers.get` answers + * `null` for an absent header; that is folded to `undefined`, the record's + * absence, and a repeated header in the record shape is one comma list, as + * RFC 9110 §5.3 reads it. + */ +function requestHeader(headers: unknown, lower: string): string | undefined { + if (!headers || typeof headers !== 'object') return undefined; + const h = headers as { get?: unknown } & Record; + const raw = typeof h.get === 'function' ? (h.get as (name: string) => unknown).call(headers, lower) : h[lower]; + if (typeof raw === 'string') return raw; + if (Array.isArray(raw)) return raw.filter((v) => typeof v === 'string').join(', '); + return undefined; +} + +/** + * [#25785] Does `If-None-Match` name the representation this request would be + * served? RFC 9110 §13.1.2: a comma list of entity tags compared WEAKLY (the + * `W/` prefix is ignored on both sides), or `*`, which any current + * representation matches. A malformed member matches nothing, so the caller is + * served the full bundle: the safe direction. + */ +function ifNoneMatchHits(header: string | undefined, etag: string): boolean { + if (header === undefined) return false; + const opaque = etag.replace(/^W\//, ''); + return header.split(',').some((member) => { + const tag = member.trim(); + return tag === '*' || tag.replace(/^W\//, '') === opaque; + }); +} + +/** + * [#25785] Answer a translations read with its validator. Every console open + * read the whole locale bundle again (80–88 KB compressed on a hosted tenant) + * because nothing let the browser revalidate it. + * + * ⛔ The ONE invariant: the validator is a hash of the bytes this answer + * serves — the whole success envelope, `locale` and `requestedLocale` + * included — computed on every request. A `304` is answered only on an exact + * match over those bytes, so a caller is only ever pinned to the bundle it + * really holds, whatever changed underneath it (an authored translation, a + * package, the environment the request resolved to). Hashing anything cheaper + * than the body — a version counter, the bundle object's identity — would + * break that silently: a missed bump is a stale `304`, and nothing reports it. + * It is weak (`W/`) because it vouches for the JSON, not for the encoding a + * compressing layer puts on the wire. + * + * The `304` carries the same `ETag` and `Cache-Control` the `200` would + * (RFC 9110 §15.4.5) and no body: `body` is left out, which both transports + * write as a body-less response (`JSON.stringify(undefined)` is `undefined`), + * pinned over a real socket in `i18n-translations-etag.hono.integration.test.ts`. + */ +function translationsAnswer( + deps: DomainHandlerDeps, + data: Record, + context: HttpProtocolContext, +): HttpDispatcherResult { + const response = deps.success(data); + const etag = `W/"${createHash('sha256').update(JSON.stringify(response.body)).digest('base64url')}"`; + const headers = { ETag: etag, 'Cache-Control': TRANSLATIONS_CACHE_CONTROL }; + if (ifNoneMatchHits(requestHeader(context?.request?.headers, 'if-none-match'), etag)) { + return { handled: true, response: { status: 304, headers } }; + } + return { handled: true, response: { ...response, headers } }; +} + export function createI18nDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/i18n', @@ -154,11 +239,11 @@ export async function handleI18nRequest( const resolved = resolveLocale(locale, availableLocales); if (resolved && resolved !== locale) { translations = i18nService.getTranslations(resolved); - return { handled: true, response: deps.success({ locale: resolved, requestedLocale: locale, translations }) }; + return translationsAnswer(deps, { locale: resolved, requestedLocale: locale, translations }, context); } } - return { handled: true, response: deps.success({ locale, translations }) }; + return translationsAnswer(deps, { locale, translations }, context); } // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx From e8c4b7a76c0f3c4dc0f2c129f2a2c23db35ae7d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:50:43 +0000 Subject: [PATCH 2/5] test(runtime): pin the translations validator in the domain body and the 304 over a real Hono socket Claude-Session: https://claude.ai/code/session_01HFAuCZPty5SsvseoPRgukQ Co-authored-by: Claude --- .../domains/i18n-translations-etag.test.ts | 230 ++++++++++++++++++ ...translations-etag.hono.integration.test.ts | 135 ++++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/runtime/src/domains/i18n-translations-etag.test.ts create mode 100644 packages/runtime/src/i18n-translations-etag.hono.integration.test.ts diff --git a/packages/runtime/src/domains/i18n-translations-etag.test.ts b/packages/runtime/src/domains/i18n-translations-etag.test.ts new file mode 100644 index 00000000000..53096bcdf5d --- /dev/null +++ b/packages/runtime/src/domains/i18n-translations-etag.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25785 — the translation bundle carries a validator, and a browser that + * holds the current bundle is answered `304` instead of the whole bundle. + * + * ## The defect + * + * `GET /i18n/translations/:locale` answered every console open with the whole + * locale bundle (288–361 KB raw, 80–88 KB compressed, measured on a hosted + * tenant) and no `ETag` or `Cache-Control`, so nothing let the browser + * revalidate what it already held. + * + * ## What is pinned + * + * - A: the `200` carries `ETag` and `Cache-Control: private, no-cache`, and its + * body is the envelope it always was. + * - B: the validator is a hash of the bytes served — read here by recomputing + * it from the body, so a validator derived from anything cheaper (a counter, + * the bundle's identity) fails this file, not production. + * - C: `If-None-Match` naming that representation, in every spelling RFC 9110 + * §13.1.2 allows (strong or weak, a list, `*`) and through both header shapes + * a transport hands in, is answered `304` with the same two headers and no + * body; a stale or malformed one is served the full bundle. + * - D: the anonymous floor still decides first — a matching validator does not + * turn the `401` into a `304`, and no refusal carries a validator. + * + * The wire half (both headers and the empty `304` over a real socket) is + * `i18n-translations-etag.hono.integration.test.ts`. + */ + +import { createHash } from 'node:crypto'; +import { describe, it, expect, vi } from 'vitest'; + +import { handleI18nRequest } from './i18n.js'; +import { apiErrorResponse } from '../error-envelope.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +const MEMBER_EC = { userId: 'usr_member', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; + +function member(headers: unknown = {}): HttpProtocolContext { + return { request: { headers }, executionContext: { ...MEMBER_EC } } as unknown as HttpProtocolContext; +} + +function anonymous(headers: unknown = {}): HttpProtocolContext { + return { request: { headers } } as unknown as HttpProtocolContext; +} + +function makeService(bundles: Record>) { + return { + getLocales: vi.fn(() => Object.keys(bundles)), + getDefaultLocale: vi.fn(() => 'en'), + getTranslations: vi.fn((locale: string) => bundles[locale] ?? {}), + }; +} + +function makeDeps(service: ReturnType): DomainHandlerDeps { + return { + getService: vi.fn(async () => service), + resolveService: vi.fn(async () => service), + // The dispatcher's own envelope builder, `meta` slot included. + success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, meta } }), + error: (message: string, httpStatus = 500, details?: any) => + apiErrorResponse({ message, httpStatus, details }), + } as unknown as DomainHandlerDeps; +} + +const FR = { objects: { lead: { label: 'Prospect', fields: { company: { label: 'Société' } } } } }; +const ZH_CN = { objects: { lead: { label: '潜在客户' } } }; + +/** The validator of a served body, recomputed from the bytes the transport writes. */ +function validatorOf(body: unknown): string { + return `W/"${createHash('sha256').update(JSON.stringify(body)).digest('base64url')}"`; +} + +async function read( + service: ReturnType, + context: HttpProtocolContext, + input: { subPath: string; query?: Record } = { subPath: '/translations/fr' }, +): Promise { + return handleI18nRequest(makeDeps(service), input.subPath, 'GET', input.query ?? {}, context); +} + +// ── A + B: the 200 and its validator ──────────────────────────────────────── + +describe('#25785 A — a translations read carries its validator and cache directive', () => { + it.each([ + ['the path spelling', { subPath: '/translations/fr' }], + ['the query spelling', { subPath: '/translations', query: { locale: 'fr' } }], + ])('%s: 200, the same body as before, plus ETag and Cache-Control: private, no-cache', async (_label, input) => { + const result = await read(makeService({ en: {}, fr: FR }), member(), input); + expect(result.handled).toBe(true); + expect(result.response.status).toBe(200); + expect(result.response.body).toEqual({ success: true, data: { locale: 'fr', translations: FR } }); + expect(result.response.headers['Cache-Control']).toBe('private, no-cache'); + expect(result.response.headers.ETag).toMatch(/^W\/"[A-Za-z0-9_-]{43}"$/); + }); + + it('the locale fallback arm (zh → zh-CN) carries them too, over its own body', async () => { + const result = await read(makeService({ en: {}, 'zh-CN': ZH_CN }), member(), { subPath: '/translations/zh' }); + expect(result.response.status).toBe(200); + expect(result.response.body.data).toEqual({ locale: 'zh-CN', requestedLocale: 'zh', translations: ZH_CN }); + expect(result.response.headers['Cache-Control']).toBe('private, no-cache'); + expect(result.response.headers.ETag).toBe(validatorOf(result.response.body)); + }); + + it('a refusal carries no validator: the missing-locale 400', async () => { + const result = await read(makeService({ fr: FR }), member(), { subPath: '/translations', query: {} }); + expect(result.response.status).toBe(400); + expect(result.response.headers).toBeUndefined(); + }); +}); + +describe('#25785 B — the validator is a hash of the bytes served, recomputed on every read', () => { + it('equals the hash of the served envelope', async () => { + const result = await read(makeService({ fr: FR }), member()); + expect(result.response.headers.ETag).toBe(validatorOf(result.response.body)); + }); + + it('is stable while the bundle is unchanged, and moves when one string changes', async () => { + const bundles: Record> = { fr: FR }; + const service = makeService(bundles); + const first = (await read(service, member())).response.headers.ETag; + expect((await read(service, member())).response.headers.ETag).toBe(first); + + bundles.fr = { objects: { lead: { label: 'Prospect', fields: { company: { label: 'Entreprise' } } } } }; + const changed = await read(service, member()); + expect(changed.response.headers.ETag).not.toBe(first); + expect(changed.response.headers.ETag).toBe(validatorOf(changed.response.body)); + }); + + it('moves when the bundle object is MUTATED in place — identity is not the validator', async () => { + const mutable = { objects: { lead: { label: 'Prospect' } } } as Record; + const service = makeService({ fr: mutable }); + const before = (await read(service, member())).response.headers.ETag; + mutable.objects.lead.label = 'Piste'; + expect((await read(service, member())).response.headers.ETag).not.toBe(before); + }); +}); + +// ── C: the conditional read ───────────────────────────────────────────────── + +describe('#25785 C — If-None-Match naming the current bundle is answered 304, with no body', () => { + async function currentValidator(): Promise { + return (await read(makeService({ fr: FR }), member())).response.headers.ETag; + } + + function expectNotModified(result: any, etag: string) { + expect(result.handled).toBe(true); + expect(result.response.status).toBe(304); + expect('body' in result.response).toBe(false); + // RFC 9110 §15.4.5: the 304 carries the ETag and Cache-Control the 200 would. + expect(result.response.headers).toEqual({ ETag: etag, 'Cache-Control': 'private, no-cache' }); + } + + it.each([ + ['the weak tag it was served', (etag: string) => etag], + ['the strong spelling of it (weak comparison)', (etag: string) => etag.replace(/^W\//, '')], + ['a list naming it after another tag', (etag: string) => `W/"stale", ${etag}`], + ['a list without spaces', (etag: string) => `"stale",${etag}`], + ['*', () => '*'], + ])('%s → 304', async (_label, spell) => { + const etag = await currentValidator(); + expectNotModified(await read(makeService({ fr: FR }), member({ 'if-none-match': spell(etag) })), etag); + }); + + it('through a Fetch Headers object (the @objectstack/hono catch-all hands its raw Request)', async () => { + const etag = await currentValidator(); + expectNotModified(await read(makeService({ fr: FR }), member(new Headers({ 'If-None-Match': etag }))), etag); + }); + + it('through a repeated header in the record shape, read as one list', async () => { + const etag = await currentValidator(); + expectNotModified(await read(makeService({ fr: FR }), member({ 'if-none-match': ['W/"stale"', etag] })), etag); + }); + + it('on the query spelling too', async () => { + const etag = await currentValidator(); + const result = await read( + makeService({ fr: FR }), member({ 'if-none-match': etag }), { subPath: '/translations', query: { locale: 'fr' } }, + ); + expectNotModified(result, etag); + }); + + it.each([ + ['a stale validator', 'W/"stale"'], + ['an unquoted value (malformed: matches nothing)', 'stale'], + ['an empty header', ''], + ])('%s is served the full bundle with the current validator', async (_label, header) => { + const etag = await currentValidator(); + const result = await read(makeService({ fr: FR }), member({ 'if-none-match': header })); + expect(result.response.status).toBe(200); + expect(result.response.body).toEqual({ success: true, data: { locale: 'fr', translations: FR } }); + expect(result.response.headers.ETag).toBe(etag); + }); + + it('a validator issued before the bundle changed is served the new bundle, never a 304', async () => { + const bundles: Record> = { fr: FR }; + const service = makeService(bundles); + const issued = (await read(service, member())).response.headers.ETag; + bundles.fr = { objects: { lead: { label: 'Piste' } } }; + const result = await read(service, member({ 'if-none-match': issued })); + expect(result.response.status).toBe(200); + expect(result.response.body.data.translations).toEqual({ objects: { lead: { label: 'Piste' } } }); + expect(result.response.headers.ETag).not.toBe(issued); + }); + + it('a validator for one locale does not answer another locale 304', async () => { + const service = makeService({ fr: FR, 'zh-CN': ZH_CN }); + const frEtag = (await read(service, member())).response.headers.ETag; + const result = await read(service, member({ 'if-none-match': frEtag }), { subPath: '/translations/zh-CN' }); + expect(result.response.status).toBe(200); + expect(result.response.body.data.translations).toEqual(ZH_CN); + }); +}); + +// ── D: the floor decides first ────────────────────────────────────────────── + +describe('#25785 D — the anonymous floor still answers before any validator is read', () => { + it('an anonymous caller holding the current validator gets 401, no 304 and no headers', async () => { + const etag = (await read(makeService({ fr: FR }), member())).response.headers.ETag; + const service = makeService({ fr: FR }); + const result = await read(service, anonymous({ 'if-none-match': etag })); + expect(result.response.status).toBe(401); + expect(result.response.body.error.code).toBe('UNAUTHENTICATED'); + expect(result.response.headers).toBeUndefined(); + expect(service.getTranslations).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/i18n-translations-etag.hono.integration.test.ts b/packages/runtime/src/i18n-translations-etag.hono.integration.test.ts new file mode 100644 index 00000000000..ff07223636d --- /dev/null +++ b/packages/runtime/src/i18n-translations-etag.hono.integration.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** + * A browser holding the current translation bundle is answered `304` with no + * body, over a real Hono socket (#25785). + * + * `GET /api/v1/i18n/translations/:locale` used to answer every console open + * with the whole locale bundle and neither `ETag` nor `Cache-Control`. The + * domain body now issues a validator (`domains/i18n-translations-etag.test.ts` + * pins its derivation); this file pins what reaches the wire through the + * standalone server's mount (`dispatcher-plugin.ts` → `sendResult` → + * `plugin-hono-server`): + * + * 1. the `200` carries both headers and the same JSON envelope as before; + * 2. a matching `If-None-Match` is answered `304` with an EMPTY body and the + * same two headers — the transport writes the body-less answer without a + * serialised `null` or `undefined`, and without throwing on a null-body + * status; + * 3. a validator issued before the bundle changed is served the new bundle; + * 4. an anonymous caller holding the validator is still refused `401`. + * + * `LiteKernel` with two stub slots: `auth` answers a session only when the + * request carries `x-test-session`, and `i18n` serves a bundle the test can + * replace between reads. + */ + +const SESSION_HEADER = 'x-test-session'; +const URL_PATH = '/api/v1/i18n/translations/fr'; + +const bundles: Record> = { + fr: { objects: { lead: { label: 'Prospect', fields: { company: { label: 'Société' } } } } }, +}; + +function fakeServicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-services-i18n-etag', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: any) => + (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_i18n_etag' } } : undefined), + }, + }); + ctx.registerService('i18n', { + getLocales: () => Object.keys(bundles), + getDefaultLocale: () => 'fr', + getTranslations: (locale: string) => bundles[locale] ?? {}, + }); + }, + }; +} + +describe('the translation bundle is revalidated with ETag and answered 304 over a real socket (integration, #25785)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(fakeServicesPlugin()); + // port 0 → an OS-assigned free port, read back through getPort(). + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1' })); + await kernel.bootstrap(); + baseUrl = `http://127.0.0.1:${kernel.getService('http.server').getPort!()}`; + }, 30_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + }, 30_000); + + function get(headers: Record) { + return fetch(`${baseUrl}${URL_PATH}`, { headers }); + } + + it('a signed-in read is 200 with ETag, Cache-Control: private, no-cache and the envelope it always had', async () => { + const res = await get({ [SESSION_HEADER]: '1' }); + expect(res.status).toBe(200); + expect(res.headers.get('etag')).toMatch(/^W\/"[A-Za-z0-9_-]{43}"$/); + expect(res.headers.get('cache-control')).toBe('private, no-cache'); + expect(await res.json()).toEqual({ success: true, data: { locale: 'fr', translations: bundles.fr } }); + }); + + it('the same read with that validator in If-None-Match is 304, with an empty body and both headers', async () => { + const first = await get({ [SESSION_HEADER]: '1' }); + await first.arrayBuffer(); + const etag = first.headers.get('etag')!; + + const res = await get({ [SESSION_HEADER]: '1', 'if-none-match': etag }); + expect(res.status).toBe(304); + expect(res.headers.get('etag')).toBe(etag); + expect(res.headers.get('cache-control')).toBe('private, no-cache'); + expect((await res.arrayBuffer()).byteLength).toBe(0); + }); + + it('a validator issued before the bundle changed is served the new bundle and a new validator', async () => { + const first = await get({ [SESSION_HEADER]: '1' }); + await first.arrayBuffer(); + const issued = first.headers.get('etag')!; + + const previous = bundles.fr; + bundles.fr = { objects: { lead: { label: 'Piste' } } }; + try { + const res = await get({ [SESSION_HEADER]: '1', 'if-none-match': issued }); + expect(res.status).toBe(200); + expect(res.headers.get('etag')).not.toBe(issued); + expect((await res.json()).data.translations).toEqual({ objects: { lead: { label: 'Piste' } } }); + } finally { + bundles.fr = previous; + } + }); + + it('an anonymous caller holding the current validator is still refused 401, with no validator', async () => { + const first = await get({ [SESSION_HEADER]: '1' }); + await first.arrayBuffer(); + const etag = first.headers.get('etag')!; + + const res = await get({ 'if-none-match': etag }); + expect(res.status).toBe(401); + expect(res.headers.get('etag')).toBeNull(); + expect((await res.json()).error.code).toBe('UNAUTHENTICATED'); + }); +}); From 0b2e6592c3bcb406558ea576d0e4c57430f55ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:51:58 +0000 Subject: [PATCH 3/5] test(hono): a body-less dispatcher 304 stays a body-less 304 through toResponse; changeset for the translations validator Claude-Session: https://claude.ai/code/session_01HFAuCZPty5SsvseoPRgukQ Co-authored-by: Claude --- .changeset/25785-i18n-translations-etag.md | 12 +++ .../src/hono-not-modified-response.test.ts | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 .changeset/25785-i18n-translations-etag.md create mode 100644 packages/adapters/hono/src/hono-not-modified-response.test.ts diff --git a/.changeset/25785-i18n-translations-etag.md b/.changeset/25785-i18n-translations-etag.md new file mode 100644 index 00000000000..32515c31410 --- /dev/null +++ b/.changeset/25785-i18n-translations-etag.md @@ -0,0 +1,12 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): `GET /i18n/translations/:locale` carries an `ETag` and `Cache-Control: private, no-cache`, and a matching `If-None-Match` is answered `304` + +Clause-②: no +Metadata-protocol: none + +- **Before.** The translation bundle (both spellings, `/i18n/translations/:locale` and `/i18n/translations?locale=`) answered every read with the whole locale bundle and neither header, so a browser re-downloaded it on every console open: 80–88 KB compressed on a hosted tenant. +- **Now.** The `200` carries a weak `ETag`, a SHA-256 of the exact envelope served (`locale`, `requestedLocale` and `translations`), computed on every read, and `Cache-Control: private, no-cache`. A request whose `If-None-Match` names that representation (a strong or weak tag, a comma list, or `*`) is answered `304` with no body and the same two headers. A bundle that changed since the validator was issued is served in full under a new validator. +- **Unchanged.** The `200` body is byte-for-byte the envelope it was. An anonymous caller is still refused `401` before any validator is read, and refusals carry neither header. `/i18n/locales` and `/i18n/labels/*` are untouched. diff --git a/packages/adapters/hono/src/hono-not-modified-response.test.ts b/packages/adapters/hono/src/hono-not-modified-response.test.ts new file mode 100644 index 00000000000..10e2f45d696 --- /dev/null +++ b/packages/adapters/hono/src/hono-not-modified-response.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25785] A dispatcher `304` reaches the caller as a body-less `304` with + * the route's own headers, through `toResponse`'s `response` arm. + * + * The `/i18n/translations` domain answers a matching `If-None-Match` with + * `{ status: 304, headers }` and no `body` (`runtime/src/domains/i18n.ts`, + * `translationsAnswer`). `toResponse` writes a `response` with `c.json(body, + * status)`; a `304` must reach the wire with NO body — a `Response` with a + * null-body status and a body throws in the Fetch constructor, and a body of + * `"null"` or `"undefined"` is not a `304` at all. This file pins that the + * body-less answer survives this adapter, and that the domain is handed the + * request headers it reads the validator from. + * + * The standalone server's half, over a real socket and the real domain, is + * `packages/runtime/src/i18n-translations-etag.hono.integration.test.ts`. This + * package's vitest config aliases `@objectstack/runtime` to a stub, so the + * dispatcher here is a fixture. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Hono } from 'hono'; + +const mockDispatcher = { + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), + handleAuth: vi.fn(), + dispatch: vi.fn(), +}; + +vi.mock('@objectstack/runtime', () => ({ + HttpDispatcher: function HttpDispatcher() { return mockDispatcher; }, +})); + +import { createHonoApp } from './index'; + +const PREFIX = '/api/v1'; +const PATH = `${PREFIX}/i18n/translations/fr`; +const ETAG = 'W/"validator-of-the-served-bundle"'; +const HEADERS = { ETag: ETAG, 'Cache-Control': 'private, no-cache' }; + +const kernel = { name: 'test-kernel' } as any; +const bootApp = (): Hono => createHonoApp({ kernel, prefix: PREFIX }); + +describe('#25785: a body-less dispatcher 304 stays a body-less 304', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDispatcher.handleAuth.mockResolvedValue({ handled: false }); + }); + + it('is written as 304 with an empty body and the route\'s ETag and Cache-Control', async () => { + mockDispatcher.dispatch.mockResolvedValue({ handled: true, response: { status: 304, headers: HEADERS } }); + + const res = await bootApp().request(`http://localhost${PATH}`, { headers: { 'If-None-Match': ETAG } }); + + expect(res.status).toBe(304); + expect((await res.arrayBuffer()).byteLength).toBe(0); + expect(res.headers.get('etag')).toBe(ETAG); + expect(res.headers.get('cache-control')).toBe('private, no-cache'); + }); + + it('hands the domain the request it reads If-None-Match from', async () => { + mockDispatcher.dispatch.mockResolvedValue({ handled: true, response: { status: 304, headers: HEADERS } }); + + await bootApp().request(`http://localhost${PATH}`, { headers: { 'If-None-Match': ETAG } }); + + expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1); + const context = mockDispatcher.dispatch.mock.calls[0][4]; + expect(context.request.headers.get('if-none-match')).toBe(ETAG); + }); + + it('the 200 beside it still carries its JSON body and the same headers (control)', async () => { + const body = { success: true, data: { locale: 'fr', translations: { objects: {} } } }; + mockDispatcher.dispatch.mockResolvedValue({ handled: true, response: { status: 200, body, headers: HEADERS } }); + + const res = await bootApp().request(`http://localhost${PATH}`); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual(body); + expect(res.headers.get('etag')).toBe(ETAG); + expect(res.headers.get('cache-control')).toBe('private, no-cache'); + }); +}); From 7b2a1a2ba2f7399e3801dd072441698789eb99a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 04:04:40 +0000 Subject: [PATCH 4/5] test(runtime): type the integration test's parsed bodies for the test-typecheck ratchet Claude-Session: https://claude.ai/code/session_01HFAuCZPty5SsvseoPRgukQ Co-authored-by: Claude --- .../src/i18n-translations-etag.hono.integration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/i18n-translations-etag.hono.integration.test.ts b/packages/runtime/src/i18n-translations-etag.hono.integration.test.ts index ff07223636d..a3c2de63be8 100644 --- a/packages/runtime/src/i18n-translations-etag.hono.integration.test.ts +++ b/packages/runtime/src/i18n-translations-etag.hono.integration.test.ts @@ -116,7 +116,8 @@ describe('the translation bundle is revalidated with ETag and answered 304 over const res = await get({ [SESSION_HEADER]: '1', 'if-none-match': issued }); expect(res.status).toBe(200); expect(res.headers.get('etag')).not.toBe(issued); - expect((await res.json()).data.translations).toEqual({ objects: { lead: { label: 'Piste' } } }); + const json = (await res.json()) as Record; + expect(json.data.translations).toEqual({ objects: { lead: { label: 'Piste' } } }); } finally { bundles.fr = previous; } @@ -130,6 +131,7 @@ describe('the translation bundle is revalidated with ETag and answered 304 over const res = await get({ 'if-none-match': etag }); expect(res.status).toBe(401); expect(res.headers.get('etag')).toBeNull(); - expect((await res.json()).error.code).toBe('UNAUTHENTICATED'); + const json = (await res.json()) as Record; + expect(json.error.code).toBe('UNAUTHENTICATED'); }); }); From 773c3f3bc02786ba72ec530247136b0bda2e82af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 04:34:13 +0000 Subject: [PATCH 5/5] tooling(route-envelope): classify the i18n domain's two header-carrying answers (kind 1) The translations 200 carries ETag and Cache-Control, which deps.success cannot express, and the matching-If-None-Match 304 has no body. Row 0 -> 2 with its note; no other row and no gate logic moves. Claude-Session: https://claude.ai/code/session_01HFAuCZPty5SsvseoPRgukQ Co-authored-by: Claude --- scripts/check-route-envelope.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 2059dab8e25..4027a985d7d 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -994,7 +994,15 @@ const DISPATCHER_DOMAINS = { // answer it gives goes through `deps.success` / `deps.error`. 'catalog-activation.ts': { handBuilt: 0 }, 'data.ts': { handBuilt: 0 }, - 'i18n.ts': { handBuilt: 0 }, + // [#25785] Was 0. Kind 1: the translation bundle is the one `/i18n` face a + // browser revalidates. Its `200` must carry `ETag` and `Cache-Control` and + // `deps.success` takes no headers, so the answer is `deps.success`'s envelope + // spread with them; a matching `If-None-Match` is a `304`, which has no body + // at all and so no envelope a helper could build. + 'i18n.ts': { + handBuilt: 2, + note: 'the translations 200, which must carry `ETag` and `Cache-Control: private, no-cache` (`deps.success` takes none) and whose body is `deps.success`\'s envelope spread; and the body-less 304 a matching `If-None-Match` is answered, carrying the same two headers', + }, 'notifications.ts': { handBuilt: 0 }, 'packages.ts': { handBuilt: 0 }, // The `/security` domain's permission-set clone door (ADR-0126 §7.1), beside