@meginhufr/brua-web 0.1.0 → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Meginhufr AS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Your backend passes asset ids on, and the page reads each asset's current releas
10
10
 
11
11
  ## From an id
12
12
 
13
- Configure once where your organization's assets live, then render by id. The asset is fetched from `{base}/{id}.json` with no key, reused for a minute (the edge's own lifetime) by everyone who asks, and a merge in Brua reaches the page within that minute with no deploy of yours.
13
+ Configure once where your organization's assets live, then render by id. The asset is fetched from `{base}/{id}.json` with no key, and remembered by everyone who asks: answered from memory for the edge's minute, read again after that, and kept when the edge can't be reached. A merge in Brua reaches the page within that minute with no deploy of yours; `brua.cachedAsset(id)` gives the last release read at once, and `brua.prefetch(ids)` reads a list's ids before it renders.
14
14
 
15
15
  ```ts
16
16
  import { createBrua } from "@meginhufr/brua-web";
@@ -1,20 +1,22 @@
1
1
  <!-- Brua for Svelte 5: the asset as a picture element (SPEC.md 7.2). The browser picks the format, density
2
2
  and color scheme, and follows dark mode live. Animations show their still. Import from "@meginhufr/brua-web/svelte".
3
3
  Given an id instead of an asset, the current release is read from the edge through the Brua set with
4
- setBrua(); the children show until it has loaded, and when nothing is released under the id. -->
4
+ setBrua(); the last release it read shows at once, else the children until it has loaded, and when nothing is released under the id. -->
5
5
  <script>
6
6
  import { getContext } from "svelte";
7
7
  import { pictureModel } from "./picture.ts";
8
8
 
9
9
  /** @type {{ asset?: import("./types.ts").AssetResponse, id?: string, brua?: import("./brua.ts").Brua, sizes?: string, loading?: "lazy" | "eager", class?: string, colorScheme?: "light" | "dark", children?: import("svelte").Snippet }} */
10
10
  let { asset: given, id, brua = getContext("brua"), sizes, loading, class: imgClass, colorScheme, children } = $props();
11
- let fetched = $state();
11
+ // The last release the Brua read renders at once, so a remounted picture doesn't flash its children.
12
+ let fetched = $state(id === undefined ? undefined : brua?.cachedAsset(id));
12
13
 
13
14
  $effect(() => {
14
15
  if (given || id === undefined) return;
15
16
  if (!brua) throw new Error("BruaPicture with an id needs setBrua() in a parent, or a brua prop.");
16
17
  let live = true;
17
- brua.asset(id).then((loaded) => { if (live) fetched = loaded; });
18
+ fetched = brua.cachedAsset(id);
19
+ brua.asset(id).then((loaded) => { if (live) fetched = loaded; }, () => {});
18
20
  return () => { live = false; };
19
21
  });
20
22
 
package/dist/brua.d.ts CHANGED
@@ -2,18 +2,29 @@ import type { AssetResponse } from "./types.ts";
2
2
  export interface BruaOptions {
3
3
  /** The organization's asset prefix on the edge, such as https://cdn.brua.dev/assets/{org}. */
4
4
  base: string;
5
- /** The language for alt text and language-specific files; left out, the browser's Accept-Language decides. */
5
+ /** The language for alt text and language-specific files; left out, the browser's languages decide. */
6
6
  lang?: string;
7
- /** How long a fetched asset is reused before it is asked for again, in milliseconds: the edge's own minute by default. */
7
+ /** How long a fetched asset is reused before it is asked for again, in milliseconds; by default the edge's max-age. */
8
8
  maxAge?: number;
9
9
  /** The fetch to use; the global one by default. */
10
10
  fetch?: typeof fetch;
11
11
  }
12
- /** Assets by id from the edge, with no key and no backend: each id costs one small request a minute at most. */
12
+ /**
13
+ * Assets by id from the edge, with no key and no backend. Every release read is remembered: answered from memory for
14
+ * the edge's minute, asked for again after that, and kept when the edge can't be reached, so a merge lands within
15
+ * the minute and a page that loses its connection keeps its images.
16
+ */
13
17
  export interface Brua {
14
18
  /** The JSON URL of the asset's current release. */
15
19
  url(id: string): string;
16
- /** The asset's current release, or undefined when the id is unknown or nothing is released. */
20
+ /**
21
+ * The asset's current release, or undefined when the id is unknown or nothing is released. Reads of one id at the
22
+ * same time share a request. Rejects only when the edge can't be reached and no release of the id is known.
23
+ */
17
24
  asset(id: string): Promise<AssetResponse | undefined>;
25
+ /** The last release of the id this Brua has read, however old, or undefined: for a component's first render. */
26
+ cachedAsset(id: string): AssetResponse | undefined;
27
+ /** Reads these ids at the same time, so a list's pictures render at once when it appears. */
28
+ prefetch(ids: string[]): Promise<void>;
18
29
  }
19
30
  export declare function createBrua(options: BruaOptions): Brua;
package/dist/brua.js CHANGED
@@ -1,31 +1,60 @@
1
+ /** "nb-NO, en;q=0.9": the browser's languages in order, with falling quality values, as the edge reads ?lang=. */
2
+ function acceptLanguage(languages) {
3
+ return languages.slice(0, 5).map((language, index) => (index === 0 ? language : `${language};q=${(1 - index * 0.1).toFixed(1)}`)).join(", ");
4
+ }
5
+ /** The edge's max-age in milliseconds, or undefined when the response doesn't say. */
6
+ function maxAgeOf(response) {
7
+ const match = /(?:^|,)\s*max-age\s*=\s*(\d+)/i.exec(response.headers.get("Cache-Control") ?? "");
8
+ return match ? Number(match[1]) * 1000 : undefined;
9
+ }
1
10
  export function createBrua(options) {
2
11
  const base = options.base.replace(/\/+$/, "");
3
- const maxAge = options.maxAge ?? 60_000;
4
- const query = options.lang ? `?lang=${encodeURIComponent(options.lang)}` : "";
5
- const held = new Map();
6
- const url = (id) => `${base}/${encodeURIComponent(id)}.json${query}`;
7
- return {
12
+ const entries = new Map();
13
+ const reads = new Map();
14
+ // Without a language, the browser's languages go in the query (in a browser only, not on a server), so each has
15
+ // its own entry here and in the HTTP cache, the same as the other SDKs.
16
+ const languages = () => options.lang ?? (typeof window !== "undefined" && typeof navigator !== "undefined" ? acceptLanguage(navigator.languages ?? []) : "");
17
+ const url = (id) => {
18
+ const lang = languages();
19
+ return `${base}/${encodeURIComponent(id)}.json${lang ? `?lang=${encodeURIComponent(lang)}` : ""}`;
20
+ };
21
+ const read = async (id, at) => {
22
+ const response = await (options.fetch ?? fetch)(at);
23
+ const freshFor = options.maxAge ?? maxAgeOf(response) ?? 60_000;
24
+ if (response.status === 404)
25
+ return { asset: undefined, freshUntil: Date.now() + freshFor };
26
+ if (!response.ok)
27
+ throw new Error(`Brua: ${id} answered ${response.status}`);
28
+ return { asset: (await response.json()), freshUntil: Date.now() + freshFor };
29
+ };
30
+ const brua = {
8
31
  url,
9
- asset(id) {
10
- const now = Date.now();
11
- const have = held.get(id);
12
- if (have && now - have.at < maxAge)
13
- return have.result;
14
- const request = options.fetch ?? fetch;
15
- const result = request(url(id)).then(async (response) => {
16
- if (response.status === 404)
17
- return undefined;
18
- if (!response.ok)
19
- throw new Error(`Brua: ${id} answered ${response.status}`);
20
- return (await response.json());
21
- });
22
- held.set(id, { at: now, result });
23
- // A failure is asked again next time, not remembered for a minute.
24
- result.catch(() => {
25
- if (held.get(id)?.result === result)
26
- held.delete(id);
27
- });
28
- return result;
32
+ async asset(id) {
33
+ const at = url(id);
34
+ const known = entries.get(at);
35
+ if (known && Date.now() < known.freshUntil)
36
+ return known.asset;
37
+ let running = reads.get(at);
38
+ if (!running) {
39
+ running = read(id, at).then((entry) => { entries.set(at, entry); return entry; }).finally(() => reads.delete(at));
40
+ reads.set(at, running);
41
+ }
42
+ try {
43
+ return (await running).asset;
44
+ }
45
+ catch (error) {
46
+ const kept = entries.get(at);
47
+ if (kept)
48
+ return kept.asset;
49
+ throw error;
50
+ }
51
+ },
52
+ cachedAsset(id) {
53
+ return entries.get(url(id))?.asset;
54
+ },
55
+ async prefetch(ids) {
56
+ await Promise.all([...new Set(ids)].map((id) => brua.asset(id).catch(() => undefined)));
29
57
  },
30
58
  };
59
+ return brua;
31
60
  }
package/dist/react.d.ts CHANGED
@@ -11,8 +11,8 @@ export type BruaProviderProps = {
11
11
  /** Where the app's assets are read from by id: wrap the app once, then <BruaPicture id="…"> anywhere below. */
12
12
  export declare function BruaProvider(props: BruaProviderProps): ReactElement;
13
13
  /**
14
- * The asset's current release for an id, from the provider's Brua or the one given: undefined until it has loaded,
15
- * and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
14
+ * The asset's current release for an id, from the provider's Brua or the one given: the last release it read at once, else
15
+ * undefined until it has loaded, and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
16
16
  */
17
17
  export declare function useBruaAsset(id: string | undefined, brua?: Brua): AssetResponse | undefined;
18
18
  export interface BruaPictureProps extends PictureOptions {
package/dist/react.js CHANGED
@@ -13,21 +13,23 @@ export function BruaProvider(props) {
13
13
  return createElement(BruaContext.Provider, { value: brua }, props.children);
14
14
  }
15
15
  /**
16
- * The asset's current release for an id, from the provider's Brua or the one given: undefined until it has loaded,
17
- * and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
16
+ * The asset's current release for an id, from the provider's Brua or the one given: the last release it read at once, else
17
+ * undefined until it has loaded, and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
18
18
  */
19
19
  export function useBruaAsset(id, brua) {
20
20
  const fromContext = useContext(BruaContext);
21
21
  const client = brua ?? fromContext;
22
- const [asset, setAsset] = useState();
22
+ // The last release the Brua read renders at once, so a remounted picture doesn't flash its fallback.
23
+ const [asset, setAsset] = useState(() => (id === undefined ? undefined : client?.cachedAsset(id)));
23
24
  if (id !== undefined && !client)
24
25
  throw new Error("useBruaAsset needs a <BruaProvider> above it, or a Brua passed in.");
25
26
  useEffect(() => {
26
27
  if (id === undefined || !client)
27
28
  return;
28
29
  let live = true;
30
+ setAsset(client.cachedAsset(id));
29
31
  client.asset(id).then((loaded) => { if (live)
30
- setAsset(loaded); });
32
+ setAsset(loaded); }, () => { });
31
33
  return () => { live = false; };
32
34
  }, [client, id]);
33
35
  return id === undefined ? undefined : asset;
package/dist/svelte.d.ts CHANGED
@@ -9,8 +9,8 @@ export declare const bruaContext = "brua";
9
9
  export declare function setBrua(options: Brua | BruaOptions): Brua;
10
10
  export declare function getBrua(): Brua | undefined;
11
11
  /**
12
- * A store of the asset's current release for an id: undefined until it has loaded, and when nothing is released
13
- * under the id. Reads through the context's Brua unless one is given; call it during component setup.
12
+ * A store of the asset's current release for an id: the last release the Brua read at once, else undefined until it
13
+ * has loaded, and when nothing is released under the id. Reads through the context's Brua unless one is given; call it during component setup.
14
14
  */
15
15
  export declare function bruaAsset(id: string, brua?: Brua | undefined): Readable<AssetResponse | undefined>;
16
16
  /**
package/dist/svelte.js CHANGED
@@ -17,16 +17,16 @@ export function getBrua() {
17
17
  return getContext(bruaContext);
18
18
  }
19
19
  /**
20
- * A store of the asset's current release for an id: undefined until it has loaded, and when nothing is released
21
- * under the id. Reads through the context's Brua unless one is given; call it during component setup.
20
+ * A store of the asset's current release for an id: the last release the Brua read at once, else undefined until it
21
+ * has loaded, and when nothing is released under the id. Reads through the context's Brua unless one is given; call it during component setup.
22
22
  */
23
23
  export function bruaAsset(id, brua = getBrua()) {
24
24
  if (!brua)
25
25
  throw new Error("bruaAsset needs setBrua() in a parent, or a Brua passed in.");
26
- return readable(undefined, (set) => {
26
+ return readable(brua.cachedAsset(id), (set) => {
27
27
  let live = true;
28
28
  brua.asset(id).then((loaded) => { if (live)
29
- set(loaded); });
29
+ set(loaded); }, () => { });
30
30
  return () => { live = false; };
31
31
  });
32
32
  }
package/dist/vue.d.ts CHANGED
@@ -6,8 +6,8 @@ export declare const bruaKey: InjectionKey<Brua>;
6
6
  /** Where the app's assets are read from by id: call once in the root component, then <BruaPicture id="…"> anywhere below. */
7
7
  export declare function provideBrua(options: Brua | BruaOptions): Brua;
8
8
  /**
9
- * The asset's current release for an id, from the provided Brua or the one given: undefined until it has loaded,
10
- * and when nothing is released under the id. A server render shows the fallback.
9
+ * The asset's current release for an id, from the provided Brua or the one given: the last release it read at once, else
10
+ * undefined until it has loaded, and when nothing is released under the id. A server render shows the fallback.
11
11
  */
12
12
  export declare function useBruaAsset(id: MaybeRefOrGetter<string | undefined>, brua?: Brua): Ref<AssetResponse | undefined>;
13
13
  /**
package/dist/vue.js CHANGED
@@ -12,8 +12,8 @@ export function provideBrua(options) {
12
12
  return brua;
13
13
  }
14
14
  /**
15
- * The asset's current release for an id, from the provided Brua or the one given: undefined until it has loaded,
16
- * and when nothing is released under the id. A server render shows the fallback.
15
+ * The asset's current release for an id, from the provided Brua or the one given: the last release it read at once, else
16
+ * undefined until it has loaded, and when nothing is released under the id. A server render shows the fallback.
17
17
  */
18
18
  export function useBruaAsset(id, brua) {
19
19
  const client = brua ?? inject(bruaKey, undefined);
@@ -28,8 +28,9 @@ export function useBruaAsset(id, brua) {
28
28
  throw new Error("useBruaAsset needs provideBrua() in a parent, or a Brua passed in.");
29
29
  let live = true;
30
30
  onCleanup(() => { live = false; });
31
+ asset.value = client.cachedAsset(wanted);
31
32
  client.asset(wanted).then((loaded) => { if (live)
32
- asset.value = loaded; });
33
+ asset.value = loaded; }, () => { });
33
34
  });
34
35
  return asset;
35
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meginhufr/brua-web",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Brua for the web: AssetResponse types, the variant selection rule, <picture> rendering, and components for React, Vue and Svelte.",
5
5
  "author": "Meginhufr AS <hello@brua.dev> (https://brua.dev)",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/BruaPicture.svelte dist/",
39
- "test": "node --test test/"
39
+ "test": "node --test \"test/**/*.test.ts\""
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public",