@alxia/language 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,482 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by the text you see: the error the plugin throws at
4
+ start-up, an error from `tsc`, or — for a trap that prints nothing — what
5
+ the response does that you did not expect.
6
+
7
+ **At start-up**
8
+
9
+ - [`TypeError: language(): the fallback "de" is not supported`](#typeerror-language-the-fallback-de-is-not-supported)
10
+
11
+ **Types**
12
+
13
+ - [`Type '"de"' is not assignable to type '"en" | "fr"'`](#type-de-is-not-assignable-to-type-en--fr)
14
+ - [`Property 'language' does not exist on type 'Context<Empty, "/", Empty>'`](#property-language-does-not-exist-on-type-contextempty--empty)
15
+ - [`Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback`](#type-alxiaempty-empty--never-is-missing-the-following-properties-from-type-languageoptionsstring-basecontext-supported-fallback)
16
+ - [`Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ en: string; fr: string; }'`](#element-implicitly-has-an-any-type-because-expression-of-type-string-cant-be-used-to-index-type--en-string-fr-string-)
17
+ - [`Type 'string | null' is not assignable to type 'string | undefined'`](#type-string--null-is-not-assignable-to-type-string--undefined)
18
+ - [`Type 'Promise<string>' is not assignable to type 'string'`](#type-promisestring-is-not-assignable-to-type-string)
19
+ - [`Property 'user' does not exist on type 'BaseContext'`](#property-user-does-not-exist-on-type-basecontext)
20
+ - [`the plugin reads "user", which this app's context does not give: use the plugin that adds it first`](#the-plugin-reads-user-which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first)
21
+ - [`the plugin reads "user", which this app's context gives with another type`](#the-plugin-reads-user-which-this-apps-context-gives-with-another-type)
22
+ - [`the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`](#the-plugins-resolve-reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated)
23
+
24
+ **Responses**
25
+
26
+ - [The browser gets French, `curl` gets English](#the-browser-gets-french-curl-gets-english)
27
+ - [`?lang=fr` works, but the next page is back in the browser's language](#langfr-works-but-the-next-page-is-back-in-the-browsers-language)
28
+ - [A cache serves one language to every visitor](#a-cache-serves-one-language-to-every-visitor)
29
+ - [`404 {"error":"not_found"}` on `/fr/products`](#404-errornot_found-on-frproducts)
30
+ - [`Accept-Language: *` gets the first supported language, not the fallback](#accept-language--gets-the-first-supported-language-not-the-fallback)
31
+ - [A visitor from Portugal gets `pt-BR`](#a-visitor-from-portugal-gets-pt-br)
32
+
33
+ ## At start-up
34
+
35
+ ### `TypeError: language(): the fallback "de" is not supported`
36
+
37
+ **When:** `language()` is called — when the module that builds the app is
38
+ imported, before it serves anything — with a `fallback` that is not in
39
+ `supported`.
40
+
41
+ **Why:** the fallback is the language of every request that names none
42
+ the app speaks, so it must be one of them. The types refuse it when they
43
+ can see the strings; they cannot when `supported` is typed `string[]` — a
44
+ list built at runtime, read from configuration, or declared without
45
+ `as const` — or when `fallback` comes from the environment through a cast.
46
+
47
+ **Fix:** list the fallback among the supported languages, and keep the
48
+ list literal so the compiler checks it next time:
49
+
50
+ ```ts
51
+ const supported = ['en', 'fr', 'de'] as const;
52
+
53
+ language({ supported, fallback: 'de' });
54
+ ```
55
+
56
+ ## Types
57
+
58
+ ### `Type '"de"' is not assignable to type '"en" | "fr"'`
59
+
60
+ ```text
61
+ error TS2322: Type '"de"' is not assignable to type '"en" | "fr"'.
62
+ ```
63
+
64
+ **When:** `language({ supported: ['en', 'fr'], fallback: 'de' })`.
65
+
66
+ **Why:** `fallback` is typed as one of `supported`. This is the compile-time
67
+ form of [the start-up error](#typeerror-language-the-fallback-de-is-not-supported).
68
+
69
+ **Fix:** a fallback the app supports:
70
+
71
+ ```ts
72
+ language({ supported: ['en', 'fr'], fallback: 'en' });
73
+ ```
74
+
75
+ ### `Property 'language' does not exist on type 'Context<Empty, "/", Empty>'`
76
+
77
+ ```text
78
+ error TS2339: Property 'language' does not exist on type 'Context<Empty, "/", Empty>'.
79
+ ```
80
+
81
+ **When:** a route reads `language` but is declared before
82
+ `.use(language(…))`, or in another app or group than the one that uses it.
83
+
84
+ **Why:** the plugin is a route hook: it applies to the routes declared
85
+ after it, at runtime and in the types alike. A route before it never runs
86
+ it.
87
+
88
+ **Fix:** use the plugin first:
89
+
90
+ ```ts
91
+ const app = alxia()
92
+ .use(language({ supported: ['en', 'fr'], fallback: 'en' }))
93
+ .get('/', ({ language: current, reply }) => reply(200, current));
94
+ ```
95
+
96
+ ### `Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback`
97
+
98
+ ```text
99
+ error TS2769: No overload matches this call.
100
+ Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => …): Alxia<…>', gave the following error.
101
+ Argument of type '<const L extends string, Ctx extends object = BaseContext>(options: LanguageOptions<L, Ctx>) => Alxia<RequiresOf<Ctx, "resolve"> & LanguageContext<L>, Empty, "", never> & Requiring<...>' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => …'.
102
+ Types of parameters 'options' and 'app' are incompatible.
103
+ Type 'Alxia<Empty, Empty, "", never>' is missing the following properties from type 'LanguageOptions<string, BaseContext>': supported, fallback
104
+ ```
105
+
106
+ **When:** `app.use(language)`, without calling it.
107
+
108
+ **Why:** `language` makes the plugin from its options; it is not the plugin.
109
+
110
+ **Fix:**
111
+
112
+ ```ts
113
+ alxia().use(language({ supported: ['en', 'fr'], fallback: 'en' }));
114
+ ```
115
+
116
+ ### `Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ en: string; fr: string; }'`
117
+
118
+ ```text
119
+ error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ en: string; fr: string; }'.
120
+ No index signature with a parameter of type 'string' was found on type '{ en: string; fr: string; }'.
121
+ ```
122
+
123
+ **When:** a route indexes a record with `language`, and `supported` was
124
+ declared apart from the call without `as const`:
125
+
126
+ ```ts
127
+ const langs = ['en', 'fr']; // string[]
128
+ alxia()
129
+ .use(language({ supported: langs, fallback: 'en' }))
130
+ .get('/', ({ language: current, reply }) => reply(200, greetings[current]));
131
+ ```
132
+
133
+ **Why:** `language` is typed from the strings of `supported`. A list typed
134
+ `string[]` has none, so `language` is a plain `string` — and the fallback
135
+ is not checked either.
136
+
137
+ **Fix:** declare the list `as const`, and derive its type from it:
138
+
139
+ ```ts
140
+ const supported = ['en', 'fr'] as const;
141
+ type Language = (typeof supported)[number]; // 'en' | 'fr'
142
+
143
+ const greetings: Record<Language, string> = { en: 'Hello', fr: 'Bonjour' };
144
+
145
+ alxia()
146
+ .use(language({ supported, fallback: 'en' }))
147
+ .get('/', ({ language: current, reply }) => reply(200, greetings[current]));
148
+ ```
149
+
150
+ ### `Type 'string | null' is not assignable to type 'string | undefined'`
151
+
152
+ ```text
153
+ error TS2322: Type '(ctx: BaseContext) => string | null' is not assignable to type '(ctx: BaseContext) => string | undefined'.
154
+ Type 'string | null' is not assignable to type 'string | undefined'.
155
+ ```
156
+
157
+ **When:** `resolve` returns a header or a search parameter as it is:
158
+ `resolve: (ctx) => ctx.request.headers.get('x-preferred-language')`.
159
+
160
+ **Why:** `Headers.get` and `URLSearchParams.get` return `null` for a
161
+ missing value; `resolve` says "none" with `undefined`.
162
+
163
+ **Fix:**
164
+
165
+ ```ts
166
+ language({
167
+ supported: ['en', 'fr'],
168
+ fallback: 'en',
169
+ resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined,
170
+ });
171
+ ```
172
+
173
+ ### `Type 'Promise<string>' is not assignable to type 'string'`
174
+
175
+ ```text
176
+ error TS2322: Type '() => Promise<string>' is not assignable to type '(ctx: BaseContext) => string | undefined'.
177
+ Type 'Promise<string>' is not assignable to type 'string'.
178
+ ```
179
+
180
+ **When:** `resolve` is `async` — it looks a user's saved preference up in a
181
+ database or a session store.
182
+
183
+ **Why:** `resolve` is synchronous: it runs on every request, after the
184
+ sources, and returns a tag or `undefined`.
185
+
186
+ **Fix:** keep the preference where the plugin already reads it. When the
187
+ user saves it, write it to the cookie; every request after reads it from
188
+ there, before `Accept-Language`:
189
+
190
+ ```ts
191
+ const supported = ['en', 'fr'] as const;
192
+
193
+ const app = alxia()
194
+ .use(language({ supported, fallback: 'en' }))
195
+ .put('/preferences/language/:lang', ({ params, set, reply }) => {
196
+ const chosen = match(params.lang, supported);
197
+ if (chosen === undefined) return reply(400, { error: 'unsupported_language' as const });
198
+ set.cookies.set('language', chosen, { path: '/', sameSite: 'lax', maxAge: 365 * 24 * 60 * 60 });
199
+ return reply(200, { language: chosen });
200
+ });
201
+ ```
202
+
203
+ Do the same at sign-in, from the stored preference.
204
+
205
+ Or load it before the plugin, in an async `derive` or the plugin that
206
+ signs the user in, and annotate `resolve` to read it — see
207
+ [Reading the app's context](guide.md#reading-the-apps-context):
208
+
209
+ ```ts
210
+ const app = alxia()
211
+ .derive(async ({ request }) => ({ saved: await preferences.find(request) }))
212
+ .use(
213
+ language({
214
+ supported,
215
+ fallback: 'en',
216
+ resolve: ({ saved }: { saved: string | undefined }) => saved,
217
+ }),
218
+ );
219
+ ```
220
+
221
+ ### `Property 'user' does not exist on type 'BaseContext'`
222
+
223
+ ```text
224
+ error TS2339: Property 'user' does not exist on type 'BaseContext'.
225
+ ```
226
+
227
+ **When:** `resolve` reads something a `derive` or a plugin before
228
+ `language()` added — a user, a session — and its parameter is not
229
+ annotated: `resolve: (ctx) => ctx.user.language`.
230
+
231
+ **Why:** an unannotated `resolve` is typed with the request's `BaseContext`
232
+ — `request`, `url`, `ip`, `pathParams`, `set` — not with what other hooks
233
+ added. `language()` is built before it is used, so it cannot see the app it
234
+ will be used on.
235
+
236
+ **Fix:** annotate the parameter with what it reads. `language()` infers it
237
+ from the annotation, and the app that uses the plugin must then give it,
238
+ before the plugin:
239
+
240
+ ```ts
241
+ import type { BaseContext } from '@alxia/core';
242
+
243
+ const byUser = language({
244
+ supported: ['en', 'fr'],
245
+ fallback: 'en',
246
+ resolve: ({ user }: BaseContext & { user: User }) => user.language ?? undefined,
247
+ });
248
+
249
+ alxia().use(auth).use(byUser); // auth derives user
250
+ ```
251
+
252
+ See [Reading the app's context](guide.md#reading-the-apps-context).
253
+
254
+ ### `the plugin reads "user", which this app's context does not give: use the plugin that adds it first`
255
+
256
+ ```text
257
+ error TS2769: No overload matches this call.
258
+ …
259
+ Types of property ''~requires'' are incompatible.
260
+ Type '{ user: User; }' is not assignable to type '"the plugin reads \"user\", which this app's context does not give: use the plugin that adds it first"'.
261
+ ```
262
+
263
+ **When:** the plugin's `resolve` is annotated to read `user`, and it is
264
+ used on an app — or in a group — whose context has no `user` at that point:
265
+ `alxia().use(byUser)`, or `use(byUser)` before `use(auth)`.
266
+
267
+ **Why:** an annotated `resolve` makes the plugin require what it reads, and
268
+ `use` checks the app's context against it, so `resolve` never runs without
269
+ it.
270
+
271
+ **Fix:** use the plugin that adds `user` first, with the type `resolve`
272
+ reads:
273
+
274
+ ```ts
275
+ alxia().use(auth).use(byUser);
276
+ ```
277
+
278
+ More on this message in
279
+ [`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first).
280
+
281
+ ### `the plugin reads "user", which this app's context gives with another type`
282
+
283
+ ```text
284
+ error TS2769: No overload matches this call.
285
+ …
286
+ Type '{ user: User; }' is not assignable to type '"the plugin reads \"user\", which this app's context gives with another type"'.
287
+ ```
288
+
289
+ **When:** the app gives a `user`, but of a type that does not fit the one
290
+ `resolve`'s parameter is annotated with: a `User | null` where `resolve`
291
+ reads `User`, or a user of another shape.
292
+
293
+ **Why:** `use` checks each key the plugin reads against the app's context;
294
+ a narrower type passes, a wider or different one does not.
295
+
296
+ **Fix:** annotate `resolve` with the type the app gives — `User | null`,
297
+ handled inside — or narrow it in the plugin before:
298
+
299
+ ```ts
300
+ language({
301
+ supported: ['en', 'fr'],
302
+ fallback: 'en',
303
+ resolve: ({ user }: { user: User | null }) => user?.language ?? undefined,
304
+ });
305
+ ```
306
+
307
+ More on this message in
308
+ [`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-gives-with-another-type).
309
+
310
+ ### `the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated`
311
+
312
+ ```text
313
+ error TS2769: No overload matches this call.
314
+ …
315
+ Types of property ''~requires'' are incompatible.
316
+ Type '{ readonly '~any': "the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"; }' is not assignable to type '"the plugin's resolve reads its context as any: annotate what it reads, or leave it unannotated"'.
317
+ ```
318
+
319
+ **When:** `resolve`'s parameter is annotated `any` —
320
+ `resolve: (ctx: any) => ctx.user.language` — and the plugin is used, on
321
+ any app, whatever its context gives.
322
+
323
+ **Why:** an `any` parameter reads any key and says nothing of what it
324
+ reads, so the plugin would require nothing, and an app without a `user`
325
+ would be accepted, and throw on every request. The plugin is refused
326
+ instead.
327
+
328
+ **Fix:** annotate what `resolve` reads, and use the plugin that adds it
329
+ first:
330
+
331
+ ```ts
332
+ const byUser = language({
333
+ supported: ['en', 'fr'],
334
+ fallback: 'en',
335
+ resolve: ({ user }: BaseContext & { user: User }) => user.language ?? undefined,
336
+ });
337
+
338
+ alxia().use(auth).use(byUser);
339
+ ```
340
+
341
+ Or leave it unannotated when it reads only the request:
342
+ `resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined`.
343
+
344
+ More on this message in
345
+ [`@alxia/core`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugins--reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated).
346
+
347
+ ## Responses
348
+
349
+ ### The browser gets French, `curl` gets English
350
+
351
+ **When:** a page opened in a browser is in the user's language, while the
352
+ same URL fetched with `curl`, a server-side `fetch`, or a test is in the
353
+ fallback, with `languageSource: 'fallback'`.
354
+
355
+ **Why:** a browser sends `Accept-Language` with every request —
356
+ `fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7` — and `curl` and most HTTP clients
357
+ send none. With no query and no cookie either, nothing names a language,
358
+ and `fallback` decides.
359
+
360
+ **Fix:** send the header a browser sends, or name the language:
361
+
362
+ ```sh
363
+ curl -H 'Accept-Language: fr-FR,fr;q=0.9,en;q=0.8' http://localhost:3000/
364
+ curl 'http://localhost:3000/?lang=fr'
365
+ ```
366
+
367
+ ```ts
368
+ await app.request('/', { headers: { 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8' } });
369
+ ```
370
+
371
+ ### `?lang=fr` works, but the next page is back in the browser's language
372
+
373
+ **When:** a language switcher links to `?lang=fr`; that page is in French,
374
+ the next one, without the query, is not.
375
+
376
+ **Why:** the query only decides the request that carries it. The plugin
377
+ keeps it for the next ones only when all of these hold:
378
+
379
+ - `persist` is set — it is off by default;
380
+ - `order` lists `'cookie'` — the cookie is written, but never read back
381
+ with `order: ['query', 'header']`;
382
+ - the browser keeps the cookie. It is `Secure` by default, and a browser
383
+ drops a `Secure` cookie set over plain `http` — on a LAN address or a
384
+ development hostname, and on `localhost` too in some browsers, Safari
385
+ among them.
386
+
387
+ **Fix:**
388
+
389
+ ```ts
390
+ language({
391
+ supported: ['en', 'fr'],
392
+ fallback: 'en',
393
+ order: ['query', 'cookie', 'header'],
394
+ persist: { secure: process.env['NODE_ENV'] === 'production' },
395
+ });
396
+ ```
397
+
398
+ ### A cache serves one language to every visitor
399
+
400
+ **When:** behind a CDN, a reverse proxy, or `@alxia/cache`, the first
401
+ visitor's language is served to everyone after.
402
+
403
+ **Why:** the response depends on what the plugin read, and a cache keyed
404
+ on the URL alone ignores it. The plugin says `Vary: Accept-Language,
405
+ Cookie` with the default `order`, and a reply's own `Vary` adds to it. Two
406
+ things still leave a header out:
407
+
408
+ - what `resolve` reads, unless the `vary` option names it;
409
+ - an `onResponse` hook that sets `Vary` with `headers.set` instead of
410
+ `@alxia/core`'s `vary`, which replaces every name before it.
411
+
412
+ **Fix:** name what `resolve` reads, add to `Vary` rather than setting it,
413
+ and give a cache the same headers:
414
+
415
+ ```ts
416
+ import { alxia, vary, withHeaders } from '@alxia/core';
417
+
418
+ alxia()
419
+ .use(
420
+ language({
421
+ supported: ['en', 'fr'],
422
+ fallback: 'en',
423
+ resolve: (ctx) => ctx.request.headers.get('x-preferred-language') ?? undefined,
424
+ vary: ['X-Preferred-Language'],
425
+ }),
426
+ )
427
+ .onResponse((response) => withHeaders(response, (headers) => vary(headers, 'Accept-Encoding')));
428
+ // Vary: Accept-Language, Cookie, X-Preferred-Language, Accept-Encoding
429
+ ```
430
+
431
+ ```ts
432
+ cache({ ttl: 60, vary: ['accept-language', 'cookie', 'x-preferred-language'] }); // @alxia/cache
433
+ ```
434
+
435
+ ### `404 {"error":"not_found"}` on `/fr/products`
436
+
437
+ **When:** `order` lists `'path'`, and the routes are declared without the
438
+ language segment: `.get('/products', …)`.
439
+
440
+ **Why:** the plugin reads the segment at `pathIndex`; it does not remove
441
+ it from the path or route on it. `/fr/products` matches no route, so the
442
+ `404` is answered before the plugin runs.
443
+
444
+ **Fix:** declare the segment in the routes:
445
+
446
+ ```ts
447
+ alxia()
448
+ .use(language({ supported: ['en', 'fr'], fallback: 'en', order: ['path', 'header'] }))
449
+ .get('/:lang/products', ({ language: current, reply }) => reply(200, current));
450
+ ```
451
+
452
+ ### `Accept-Language: *` gets the first supported language, not the fallback
453
+
454
+ **When:** a client sends `Accept-Language: *`, and `supported` does not
455
+ start with `fallback`: `supported: ['fr', 'en'], fallback: 'en'` answers
456
+ `fr`, with `languageSource: 'header'`.
457
+
458
+ **Why:** `*` accepts any language, so it matches one — the first of
459
+ `supported`. `fallback` is only for a request that names none.
460
+
461
+ **Fix:** list the language a wildcard should get first, usually the
462
+ fallback:
463
+
464
+ ```ts
465
+ language({ supported: ['en', 'fr'], fallback: 'en' });
466
+ ```
467
+
468
+ ### A visitor from Portugal gets `pt-BR`
469
+
470
+ **When:** `supported` holds `'pt-BR'` and no other Portuguese, and the
471
+ browser sends `pt-PT` or `pt`.
472
+
473
+ **Why:** a tag the app does not support exactly falls back to its base
474
+ language, then to a region of it: `pt-PT` is `pt-BR`, as `fr-CA` is `fr`.
475
+ That is usually what you want; when it is not, support the region.
476
+
477
+ **Fix:** list both regions — an exact tag always wins, and `pt` alone gets
478
+ the first one listed:
479
+
480
+ ```ts
481
+ language({ supported: ['en', 'pt-PT', 'pt-BR'], fallback: 'en' });
482
+ ```
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@alxia/language",
3
+ "version": "0.1.0",
4
+ "description": "The request's language for alxia, typed as the languages you support: from the query, a cookie, the path or Accept-Language, weights and regions included",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/softistx/alxia.git",
27
+ "directory": "packages/language"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run ../../build.ts",
35
+ "test": "bun test src",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "alxia": {
39
+ "entrypoints": [
40
+ "src/index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@alxia/core": "^0.1.0",
45
+ "@types/bun": "^1.4.2"
46
+ },
47
+ "peerDependencies": {
48
+ "@alxia/core": "^0.1.0",
49
+ "typescript": "^6.0.3 || ^7.0.0"
50
+ }
51
+ }