@gyeonghokim/authenticated-image 0.0.0-stage → 1.0.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 +21 -0
- package/README.md +310 -2
- package/dist/index-Cc9WrVMP.d.mts +117 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/react.d.mts +16 -0
- package/dist/react.mjs +25 -0
- package/dist/src-BMw-CcQI.mjs +579 -0
- package/package.json +93 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GyeongHo Kim <me@gyeongho.dev>
|
|
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
|
@@ -1,3 +1,311 @@
|
|
|
1
|
-
#
|
|
1
|
+
# authenticated-image
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> A drop-in `<img>` for images behind `Authorization` headers.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@gyeonghokim/authenticated-image)
|
|
6
|
+
[](https://bundlejs.com/?q=@gyeonghokim/authenticated-image)
|
|
7
|
+
[](https://github.com/GyeongHoKim/authenticated-image/actions/workflows/release.yml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
`<img src>` can't send an `Authorization` header. `<authenticated-image>` fetches the image with your token, displays it, and cleans up after itself. It is a Web Component, so it works in any framework, and it ships a React wrapper.
|
|
11
|
+
|
|
12
|
+
## Before / After
|
|
13
|
+
|
|
14
|
+
**Before:** fetch the image yourself and manage the object URL.
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const img = document.querySelector('img');
|
|
18
|
+
|
|
19
|
+
fetch('/api/avatar', { headers: { Authorization: `Bearer ${token}` } })
|
|
20
|
+
.then((res) => {
|
|
21
|
+
if (!res.ok) throw new Error(res.statusText);
|
|
22
|
+
return res.blob();
|
|
23
|
+
})
|
|
24
|
+
.then((blob) => {
|
|
25
|
+
img.src = URL.createObjectURL(blob);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Still to do: abort when src changes, revoke the URL on unmount,
|
|
29
|
+
// lazy-load, dedupe repeated images, show errors...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**After:** configure the token once and use the tag.
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { configure } from '@gyeonghokim/authenticated-image';
|
|
36
|
+
|
|
37
|
+
configure({
|
|
38
|
+
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```html
|
|
43
|
+
<authenticated-image src="/api/avatar" alt="Avatar"></authenticated-image>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm install @gyeonghokim/authenticated-image
|
|
50
|
+
# or
|
|
51
|
+
pnpm add @gyeonghokim/authenticated-image
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
// main.js: run once when your app starts
|
|
58
|
+
import { configure } from '@gyeonghokim/authenticated-image';
|
|
59
|
+
|
|
60
|
+
configure({
|
|
61
|
+
headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<authenticated-image src="/api/invoices/42/scan" alt="Invoice scan" loading="lazy" width="320" height="240">
|
|
67
|
+
<span slot="loading">Loading…</span>
|
|
68
|
+
<span slot="error">Could not load the image.</span>
|
|
69
|
+
</authenticated-image>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Importing the package registers the `<authenticated-image>` tag.
|
|
73
|
+
|
|
74
|
+
## Features
|
|
75
|
+
|
|
76
|
+
- Sends your `Authorization` header, or loads through your own HTTP client (axios, ky, …).
|
|
77
|
+
- Cancels stale requests when `src` changes and revokes object URLs when removed.
|
|
78
|
+
- `loading="lazy"`, starting 200px before the element enters the viewport.
|
|
79
|
+
- One request for an image shown many times on the page.
|
|
80
|
+
- `loading` and `error` slots, plus a `status` attribute for styling.
|
|
81
|
+
- Presigned URLs, refreshed once if they fail to load.
|
|
82
|
+
- Works in any framework, with a React wrapper included.
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
### React
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
'use client';
|
|
90
|
+
|
|
91
|
+
import { useRef } from 'react';
|
|
92
|
+
import {
|
|
93
|
+
AuthenticatedImage,
|
|
94
|
+
type AuthenticatedImageElement,
|
|
95
|
+
} from '@gyeonghokim/authenticated-image/react';
|
|
96
|
+
|
|
97
|
+
export function Avatar({ userId }: { userId: string }) {
|
|
98
|
+
const ref = useRef<AuthenticatedImageElement>(null);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<AuthenticatedImage
|
|
102
|
+
ref={ref}
|
|
103
|
+
src={`/api/users/${userId}/avatar`}
|
|
104
|
+
alt="Avatar"
|
|
105
|
+
loading="lazy"
|
|
106
|
+
onError={(event) => console.warn(event.detail.error.status)}
|
|
107
|
+
>
|
|
108
|
+
<span slot="loading">Loading…</span>
|
|
109
|
+
<button slot="error" type="button" onClick={() => ref.current?.reload()}>
|
|
110
|
+
Retry
|
|
111
|
+
</button>
|
|
112
|
+
</AuthenticatedImage>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`configure` and `clearCache` are also exported from `@gyeonghokim/authenticated-image/react`. Works with React 18 and 19.
|
|
118
|
+
|
|
119
|
+
With SSR, the server renders an empty `<authenticated-image>` tag, and the image loads in the browser after hydration.
|
|
120
|
+
|
|
121
|
+
### Reuse your HTTP client
|
|
122
|
+
|
|
123
|
+
Pass a `fetcher` to keep your interceptors, token refresh and retries.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import axios from 'axios';
|
|
127
|
+
import { configure } from '@gyeonghokim/authenticated-image';
|
|
128
|
+
|
|
129
|
+
const api = axios.create({ baseURL: '/api' }); // your client with auth interceptors
|
|
130
|
+
|
|
131
|
+
configure({
|
|
132
|
+
fetcher: async (src, { signal }) => {
|
|
133
|
+
const response = await api.get<Blob>(src, { responseType: 'blob', signal });
|
|
134
|
+
return response.data;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
With [ky](https://github.com/sindresorhus/ky): `fetcher: (src, { signal }) => api.get(src, { signal }).blob()`.
|
|
140
|
+
|
|
141
|
+
### Presigned URLs
|
|
142
|
+
|
|
143
|
+
Return a string instead of a `Blob`, and it is used as `img.src` as-is.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
configure({
|
|
147
|
+
fetcher: async (src, { signal }) => {
|
|
148
|
+
const response = await api.get<{ url: string }>(`${src}/presign`, { signal });
|
|
149
|
+
return response.data.url;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If a presigned URL fails to load, for example because it expired, the element asks your `fetcher` for a new one once.
|
|
155
|
+
|
|
156
|
+
### Loading and error states
|
|
157
|
+
|
|
158
|
+
Use the `loading` and `error` slots, or style the `status` attribute (`idle`, `loading`, `loaded`, `error`).
|
|
159
|
+
|
|
160
|
+
```css
|
|
161
|
+
authenticated-image[status="error"] {
|
|
162
|
+
outline: 2px solid crimson;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Styling
|
|
167
|
+
|
|
168
|
+
The inner `<img>` sizes itself like a normal `<img>`. To fill a fixed-size box:
|
|
169
|
+
|
|
170
|
+
```css
|
|
171
|
+
authenticated-image {
|
|
172
|
+
width: 64px;
|
|
173
|
+
height: 64px;
|
|
174
|
+
border-radius: 50%;
|
|
175
|
+
overflow: hidden;
|
|
176
|
+
--authenticated-image-object-fit: cover;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
authenticated-image::part(img) {
|
|
180
|
+
width: 100%;
|
|
181
|
+
height: 100%;
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The host is an `inline-grid` that stacks the image and the slots in one cell. If you change its `display`, use `grid` or `inline-grid`.
|
|
186
|
+
|
|
187
|
+
### Per-element settings
|
|
188
|
+
|
|
189
|
+
Set `fetcher` or `headers` on one element to override the global configuration. These elements don't use the shared cache.
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
document.querySelector('#public-preview').headers = {};
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Changing `fetcher` or `headers` doesn't refetch, so passing a new inline object on every React render is fine. The new value takes effect on the next load, when `src` changes. Call `reload()` to apply it immediately.
|
|
196
|
+
|
|
197
|
+
### Logging out
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
import { clearCache } from '@gyeonghokim/authenticated-image';
|
|
201
|
+
|
|
202
|
+
await logout();
|
|
203
|
+
clearCache();
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Images already on screen stay visible. New elements fetch again. Calling `configure()` also clears the cache.
|
|
207
|
+
|
|
208
|
+
## API
|
|
209
|
+
|
|
210
|
+
### Attributes
|
|
211
|
+
|
|
212
|
+
| Name | Type | Default | Description |
|
|
213
|
+
| ----------------- | ----------------------------- | --------- | ----------------------------------------------------------------------- |
|
|
214
|
+
| `src` | `string` | `''` | Image URL. Changing it cancels the current request. |
|
|
215
|
+
| `alt` | `string` | `''` | Alternative text for the inner `<img>`. |
|
|
216
|
+
| `loading` | `'eager' \| 'lazy'` | `'eager'` | `lazy` starts the request 200px before the element enters the viewport. |
|
|
217
|
+
| `width`, `height` | `number` | | Forwarded to the inner `<img>` to reserve space. |
|
|
218
|
+
| `decoding` | `'async' \| 'sync' \| 'auto'` | `'async'` | Forwarded to the inner `<img>`. |
|
|
219
|
+
|
|
220
|
+
### Properties
|
|
221
|
+
|
|
222
|
+
| Name | Type | Description |
|
|
223
|
+
| ---------- | -------------------------------------------- | ------------------------------------------------------- |
|
|
224
|
+
| `fetcher` | `Fetcher` | Loads this element's image. Bypasses the cache. |
|
|
225
|
+
| `headers` | `HeadersProvider` | Headers for this element's request. Bypasses the cache. |
|
|
226
|
+
| `status` | `'idle' \| 'loading' \| 'loaded' \| 'error'` | Read-only. Mirrored to the `status` attribute. |
|
|
227
|
+
| `error` | `AuthenticatedImageError \| undefined` | Read-only. Why the last load failed. |
|
|
228
|
+
| `reload()` | `() => void` | Loads the image again, skipping the cached copy. |
|
|
229
|
+
|
|
230
|
+
### Events
|
|
231
|
+
|
|
232
|
+
| Name | Type | When |
|
|
233
|
+
| ------- | ------------------------------------------------- | ---------------------------------- |
|
|
234
|
+
| `load` | `Event` | The image is decoded and rendered. |
|
|
235
|
+
| `error` | `CustomEvent<{ error: AuthenticatedImageError }>` | The request or decoding failed. |
|
|
236
|
+
|
|
237
|
+
### Slots, parts and custom properties
|
|
238
|
+
|
|
239
|
+
| Name | Kind | Description |
|
|
240
|
+
| ---------------------------------- | --------------- | -------------------------------------------------- |
|
|
241
|
+
| `loading` | slot | Shown while loading or waiting to lazy-load. |
|
|
242
|
+
| `error` | slot | Shown after a failure. |
|
|
243
|
+
| `img` | part | The inner `<img>`. |
|
|
244
|
+
| `--authenticated-image-object-fit` | custom property | `object-fit` of the inner `<img>`. Default `fill`. |
|
|
245
|
+
|
|
246
|
+
### Functions
|
|
247
|
+
|
|
248
|
+
| Name | Description |
|
|
249
|
+
| ------------------- | ------------------------------------------------------- |
|
|
250
|
+
| `configure(config)` | Replaces the global configuration and clears the cache. |
|
|
251
|
+
| `clearCache()` | Clears the shared cache. |
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
type Fetcher = (src: string, context: { signal: AbortSignal }) => Promise<Blob | string>;
|
|
255
|
+
type HeadersProvider = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
256
|
+
|
|
257
|
+
interface AuthenticatedImageConfig {
|
|
258
|
+
fetcher?: Fetcher; // takes precedence over headers
|
|
259
|
+
headers?: HeadersProvider;
|
|
260
|
+
cache?: { retainMs?: number }; // default 30000
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
class AuthenticatedImageError extends Error {
|
|
264
|
+
readonly src: string;
|
|
265
|
+
readonly status?: number; // HTTP status from the default fetch
|
|
266
|
+
// `cause` holds the original error
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Also exported: `AuthenticatedImageElement`, `AuthenticatedImageStatus`, `AuthenticatedImageErrorEvent` and `AuthenticatedImageErrorOptions`.
|
|
271
|
+
|
|
272
|
+
Precedence: element `fetcher` › element `headers` › global `fetcher` › global `headers`.
|
|
273
|
+
|
|
274
|
+
## Security
|
|
275
|
+
|
|
276
|
+
`configure({ headers })` sends your token to every `src`, including other origins. If `src` can come from untrusted input, use a `fetcher` that checks the origin:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
configure({
|
|
280
|
+
fetcher: async (src, { signal }) => {
|
|
281
|
+
const url = new URL(src, location.href);
|
|
282
|
+
if (url.origin !== location.origin) {
|
|
283
|
+
throw new Error(`Refusing to send the token to ${url.origin}`);
|
|
284
|
+
}
|
|
285
|
+
const response = await fetch(url, {
|
|
286
|
+
headers: { Authorization: `Bearer ${await getToken()}` },
|
|
287
|
+
signal,
|
|
288
|
+
});
|
|
289
|
+
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
|
|
290
|
+
return response.blob();
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
A cross-origin API must allow the header through CORS: `Access-Control-Allow-Headers: Authorization`.
|
|
296
|
+
|
|
297
|
+
## Caching
|
|
298
|
+
|
|
299
|
+
Elements that use the global configuration share an in-memory cache keyed by `src`. An image stays in memory for `retainMs` (30 seconds by default) after the last element using it disconnects, so scrolling back or switching routes is instant. Nothing is written to disk. For caching across page loads, send `Cache-Control: private, max-age=…` from your server, and the browser HTTP cache handles it.
|
|
300
|
+
|
|
301
|
+
## Browser support
|
|
302
|
+
|
|
303
|
+
Chrome, Edge, Firefox and Safari released in the last two years. CI tests Chromium, Firefox and WebKit.
|
|
304
|
+
|
|
305
|
+
## Contributing
|
|
306
|
+
|
|
307
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
308
|
+
|
|
309
|
+
## License
|
|
310
|
+
|
|
311
|
+
[MIT](LICENSE) © GyeongHo Kim
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { LitElement, PropertyValues, TemplateResult } from "lit";
|
|
2
|
+
/**
|
|
3
|
+
* Loads an image. Resolve to a `Blob` to display it through an object URL, or to a string
|
|
4
|
+
* (such as a presigned URL) to use it as `img.src` directly.
|
|
5
|
+
*/
|
|
6
|
+
type Fetcher = (src: string, context: {
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
}) => Promise<Blob | string>;
|
|
9
|
+
/** Headers for the default fetch, or a function that returns them. */
|
|
10
|
+
type HeadersProvider = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
11
|
+
/** Global configuration for every `<authenticated-image>`. */
|
|
12
|
+
interface AuthenticatedImageConfig {
|
|
13
|
+
/** Loads images. Takes precedence over `headers`. */
|
|
14
|
+
fetcher?: Fetcher;
|
|
15
|
+
/** Headers sent with the default `fetch`. */
|
|
16
|
+
headers?: HeadersProvider;
|
|
17
|
+
cache?: {
|
|
18
|
+
/**
|
|
19
|
+
* How long an unused image stays in memory, in milliseconds. `0` releases it immediately,
|
|
20
|
+
* and `Infinity` keeps it until the cache is cleared.
|
|
21
|
+
* @defaultValue 30000
|
|
22
|
+
*/
|
|
23
|
+
retainMs?: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Replaces the global configuration and clears the cache.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* configure({
|
|
32
|
+
* headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
declare function configure(config: AuthenticatedImageConfig): void;
|
|
37
|
+
/**
|
|
38
|
+
* Clears the shared cache, for example on logout. Images on screen stay visible; new
|
|
39
|
+
* elements fetch again.
|
|
40
|
+
*/
|
|
41
|
+
declare function clearCache(): void;
|
|
42
|
+
/** Options for {@link AuthenticatedImageError}. */
|
|
43
|
+
interface AuthenticatedImageErrorOptions {
|
|
44
|
+
/** The `src` that failed to load. */
|
|
45
|
+
src: string;
|
|
46
|
+
/** HTTP status code, when the default fetch received a non-2xx response. */
|
|
47
|
+
status?: number;
|
|
48
|
+
/** The original error thrown by `fetch`, a fetcher or a headers function. */
|
|
49
|
+
cause?: unknown;
|
|
50
|
+
}
|
|
51
|
+
/** Describes why an `<authenticated-image>` failed to load. */
|
|
52
|
+
declare class AuthenticatedImageError extends Error {
|
|
53
|
+
readonly name = "AuthenticatedImageError";
|
|
54
|
+
/** The `src` that failed to load. */
|
|
55
|
+
readonly src: string;
|
|
56
|
+
/** HTTP status code, when the default fetch received a non-2xx response. */
|
|
57
|
+
readonly status?: number;
|
|
58
|
+
constructor(message: string, options: AuthenticatedImageErrorOptions);
|
|
59
|
+
}
|
|
60
|
+
/** Loading state of an `<authenticated-image>`. */
|
|
61
|
+
type AuthenticatedImageStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
|
62
|
+
/** The `error` event of `<authenticated-image>`. */
|
|
63
|
+
type AuthenticatedImageErrorEvent = CustomEvent<{
|
|
64
|
+
error: AuthenticatedImageError;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* An `<img>` that loads its image with `fetch`, so the request can carry an `Authorization`
|
|
68
|
+
* header.
|
|
69
|
+
*
|
|
70
|
+
* @fires load - The image has been decoded and rendered.
|
|
71
|
+
* @fires error - Loading or decoding failed. `event.detail.error` describes why.
|
|
72
|
+
* @slot loading - Shown while the image is loading or waiting to be lazily loaded.
|
|
73
|
+
* @slot error - Shown when the image failed to load.
|
|
74
|
+
* @csspart img - The inner `<img>`.
|
|
75
|
+
* @cssprop --authenticated-image-object-fit - `object-fit` of the inner image. Defaults to `fill`.
|
|
76
|
+
*/
|
|
77
|
+
declare class AuthenticatedImageElement extends LitElement {
|
|
78
|
+
static styles: import("lit").CSSResult;
|
|
79
|
+
/** URL of the image. Changing it cancels the current request. */
|
|
80
|
+
src: string;
|
|
81
|
+
/** Alternative text, forwarded to the inner `<img>`. */
|
|
82
|
+
alt: string;
|
|
83
|
+
/** `lazy` waits until the element is within 200px of the viewport before requesting. */
|
|
84
|
+
loading: 'eager' | 'lazy';
|
|
85
|
+
/** Forwarded to the inner `<img>`. */
|
|
86
|
+
decoding: 'async' | 'sync' | 'auto';
|
|
87
|
+
/** Width in pixels, forwarded to the inner `<img>`. */
|
|
88
|
+
width?: number;
|
|
89
|
+
/** Height in pixels, forwarded to the inner `<img>`. */
|
|
90
|
+
height?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Loads this element's image instead of the global configuration. Disables caching. A new
|
|
93
|
+
* value applies on the next load; call `reload()` to apply it right away.
|
|
94
|
+
*/
|
|
95
|
+
fetcher?: Fetcher;
|
|
96
|
+
/**
|
|
97
|
+
* Headers for this element's request instead of the global configuration. Disables caching.
|
|
98
|
+
* A new value applies on the next load; call `reload()` to apply it right away.
|
|
99
|
+
*/
|
|
100
|
+
headers?: HeadersProvider;
|
|
101
|
+
private readonly _loader;
|
|
102
|
+
/** Current loading state. Mirrored to the `status` attribute. */
|
|
103
|
+
get status(): AuthenticatedImageStatus;
|
|
104
|
+
/** Why the last load failed, or `undefined`. */
|
|
105
|
+
get error(): AuthenticatedImageError | undefined;
|
|
106
|
+
/** Loads the image again. Elements that use the shared cache skip the cached copy. */
|
|
107
|
+
reload(): void;
|
|
108
|
+
protected willUpdate(changed: PropertyValues<this>): void;
|
|
109
|
+
protected updated(): void;
|
|
110
|
+
protected render(): TemplateResult;
|
|
111
|
+
}
|
|
112
|
+
declare global {
|
|
113
|
+
interface HTMLElementTagNameMap {
|
|
114
|
+
'authenticated-image': AuthenticatedImageElement;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export { AuthenticatedImageErrorOptions as a, HeadersProvider as c, AuthenticatedImageError as i, clearCache as l, AuthenticatedImageErrorEvent as n, AuthenticatedImageConfig as o, AuthenticatedImageStatus as r, Fetcher as s, AuthenticatedImageElement as t, configure as u };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as AuthenticatedImageErrorOptions, c as HeadersProvider, i as AuthenticatedImageError, l as clearCache, n as AuthenticatedImageErrorEvent, o as AuthenticatedImageConfig, r as AuthenticatedImageStatus, s as Fetcher, t as AuthenticatedImageElement, u as configure } from "./index-Cc9WrVMP.mjs";
|
|
2
|
+
export { type AuthenticatedImageConfig, AuthenticatedImageElement, AuthenticatedImageError, type AuthenticatedImageErrorEvent, type AuthenticatedImageErrorOptions, type AuthenticatedImageStatus, type Fetcher, type HeadersProvider, clearCache, configure };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as AuthenticatedImageElement, i as AuthenticatedImageError, n as clearCache, r as configure } from "./src-BMw-CcQI.mjs";
|
package/dist/react.d.mts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { a as AuthenticatedImageErrorOptions, c as HeadersProvider, i as AuthenticatedImageError, l as clearCache, n as AuthenticatedImageErrorEvent, o as AuthenticatedImageConfig, r as AuthenticatedImageStatus, s as Fetcher, t as AuthenticatedImageElement, u as configure } from "./index-Cc9WrVMP.mjs";
|
|
2
|
+
import { EventName } from "@lit/react";
|
|
3
|
+
/**
|
|
4
|
+
* React component for `<authenticated-image>`. Props map to the element's properties, and
|
|
5
|
+
* `onLoad` / `onError` map to its events. `ref` points to the {@link AuthenticatedImageElement}.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <AuthenticatedImage src="/api/avatar" alt="Avatar" onError={(e) => console.warn(e.detail.error)} />
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export declare const AuthenticatedImage: import("@lit/react").ReactWebComponent<AuthenticatedImageElement, {
|
|
13
|
+
onLoad: EventName<Event>;
|
|
14
|
+
onError: EventName<AuthenticatedImageErrorEvent>;
|
|
15
|
+
}>;
|
|
16
|
+
export { type AuthenticatedImageConfig, type AuthenticatedImageElement, AuthenticatedImageError, type AuthenticatedImageErrorEvent, type AuthenticatedImageErrorOptions, type AuthenticatedImageStatus, type Fetcher, type HeadersProvider, clearCache, configure };
|
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { i as AuthenticatedImageError, n as clearCache, r as configure, t as AuthenticatedImageElement } from "./src-BMw-CcQI.mjs";
|
|
3
|
+
import { createComponent } from "@lit/react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
// src/react.ts
|
|
6
|
+
/**
|
|
7
|
+
* React component for `<authenticated-image>`. Props map to the element's properties, and
|
|
8
|
+
* `onLoad` / `onError` map to its events. `ref` points to the {@link AuthenticatedImageElement}.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <AuthenticatedImage src="/api/avatar" alt="Avatar" onError={(e) => console.warn(e.detail.error)} />
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
const AuthenticatedImage = createComponent({
|
|
16
|
+
tagName: "authenticated-image",
|
|
17
|
+
elementClass: AuthenticatedImageElement,
|
|
18
|
+
react: React,
|
|
19
|
+
events: {
|
|
20
|
+
onLoad: "load",
|
|
21
|
+
onError: "error"
|
|
22
|
+
},
|
|
23
|
+
displayName: "AuthenticatedImage"
|
|
24
|
+
});
|
|
25
|
+
export { AuthenticatedImage, AuthenticatedImageError, clearCache, configure };
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { LitElement, css, html, nothing } from "lit";
|
|
2
|
+
import { property } from "lit/decorators.js";
|
|
3
|
+
// src/authenticated-image.styles.ts
|
|
4
|
+
/** Styles of `<authenticated-image>`. */
|
|
5
|
+
const styles = css`
|
|
6
|
+
:host {
|
|
7
|
+
display: inline-grid;
|
|
8
|
+
/* Lets ::part(img) { height: 100% } resolve against a sized host. */
|
|
9
|
+
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
img,
|
|
13
|
+
slot {
|
|
14
|
+
grid-area: 1 / 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
img {
|
|
18
|
+
display: block;
|
|
19
|
+
max-width: 100%;
|
|
20
|
+
height: auto;
|
|
21
|
+
object-fit: var(--authenticated-image-object-fit, fill);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Keep the box so width and height reserve space before the image loads. */
|
|
25
|
+
img[hidden] {
|
|
26
|
+
visibility: hidden;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
slot {
|
|
30
|
+
display: block;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
slot[hidden] {
|
|
34
|
+
display: none;
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
// src/errors.ts
|
|
38
|
+
/** Describes why an `<authenticated-image>` failed to load. */
|
|
39
|
+
var AuthenticatedImageError = class extends Error {
|
|
40
|
+
constructor(message, options) {
|
|
41
|
+
super(message, { cause: options.cause });
|
|
42
|
+
this.name = "AuthenticatedImageError";
|
|
43
|
+
this.src = options.src;
|
|
44
|
+
if (options.status !== void 0) this.status = options.status;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** Wraps any thrown value in an {@link AuthenticatedImageError}. */
|
|
48
|
+
function toAuthenticatedImageError(src, error) {
|
|
49
|
+
if (error instanceof AuthenticatedImageError) return error;
|
|
50
|
+
return new AuthenticatedImageError(`Failed to load image: ${src}`, {
|
|
51
|
+
src,
|
|
52
|
+
cause: error
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// src/image-cache.ts
|
|
56
|
+
/** Converts a loader result into a displayable URL. */
|
|
57
|
+
function toImageUrl(result) {
|
|
58
|
+
if (typeof result === "string") return {
|
|
59
|
+
url: result,
|
|
60
|
+
isObjectUrl: false
|
|
61
|
+
};
|
|
62
|
+
if (result instanceof Blob) return {
|
|
63
|
+
url: URL.createObjectURL(result),
|
|
64
|
+
isObjectUrl: true
|
|
65
|
+
};
|
|
66
|
+
throw new TypeError("Expected the image loader to resolve to a Blob or a string.");
|
|
67
|
+
}
|
|
68
|
+
/** Revokes the object URL of an image. Strings are left untouched. */
|
|
69
|
+
function revokeImageUrl(image) {
|
|
70
|
+
if (image.isObjectUrl) URL.revokeObjectURL(image.url);
|
|
71
|
+
}
|
|
72
|
+
/** The largest delay `setTimeout` accepts. */
|
|
73
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
74
|
+
/** Maps `retainMs` to a timer delay. `Infinity` means the entry never expires. */
|
|
75
|
+
function normalizeRetainMs(retainMs) {
|
|
76
|
+
if (Number.isNaN(retainMs) || retainMs <= 0) return 0;
|
|
77
|
+
if (retainMs > MAX_TIMEOUT_MS) return Number.POSITIVE_INFINITY;
|
|
78
|
+
return retainMs;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* In-memory image cache keyed by `src`. Concurrent requests for the same key share one load,
|
|
82
|
+
* and an entry is revoked `retainMs` after its last lease is released. A `retainMs` that is not
|
|
83
|
+
* finite or exceeds the `setTimeout` limit keeps entries until they are detached; `NaN` and
|
|
84
|
+
* negative values mean `0`.
|
|
85
|
+
*/
|
|
86
|
+
var ImageCache = class {
|
|
87
|
+
#entries = /* @__PURE__ */ new Map();
|
|
88
|
+
#retainMs;
|
|
89
|
+
constructor(retainMs) {
|
|
90
|
+
this.#retainMs = normalizeRetainMs(retainMs);
|
|
91
|
+
}
|
|
92
|
+
/** Number of entries that new leases can still join. */
|
|
93
|
+
get size() {
|
|
94
|
+
return this.#entries.size;
|
|
95
|
+
}
|
|
96
|
+
setRetainMs(retainMs) {
|
|
97
|
+
this.#retainMs = normalizeRetainMs(retainMs);
|
|
98
|
+
}
|
|
99
|
+
acquire(key, loader) {
|
|
100
|
+
let entry = this.#entries.get(key);
|
|
101
|
+
if (!entry) {
|
|
102
|
+
entry = this.#create(key, loader);
|
|
103
|
+
this.#entries.set(key, entry);
|
|
104
|
+
}
|
|
105
|
+
if (entry.timer !== void 0) {
|
|
106
|
+
clearTimeout(entry.timer);
|
|
107
|
+
entry.timer = void 0;
|
|
108
|
+
}
|
|
109
|
+
entry.refCount++;
|
|
110
|
+
const acquired = entry;
|
|
111
|
+
let released = false;
|
|
112
|
+
return {
|
|
113
|
+
image: acquired.image,
|
|
114
|
+
release: () => {
|
|
115
|
+
if (released) return;
|
|
116
|
+
released = true;
|
|
117
|
+
this.#release(acquired);
|
|
118
|
+
},
|
|
119
|
+
detach: () => {
|
|
120
|
+
if (!acquired.detached) this.#detach(acquired);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Stops sharing every entry. */
|
|
125
|
+
detachAll() {
|
|
126
|
+
for (const entry of [...this.#entries.values()]) this.#detach(entry);
|
|
127
|
+
}
|
|
128
|
+
#create(key, loader) {
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
let entry;
|
|
131
|
+
const image = (async () => {
|
|
132
|
+
try {
|
|
133
|
+
const loaded = toImageUrl(await Promise.resolve().then(() => loader(controller.signal)));
|
|
134
|
+
entry.settled = true;
|
|
135
|
+
if (controller.signal.aborted) {
|
|
136
|
+
revokeImageUrl(loaded);
|
|
137
|
+
throw new DOMException("The image request was aborted.", "AbortError");
|
|
138
|
+
}
|
|
139
|
+
entry.loaded = loaded;
|
|
140
|
+
return loaded;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
entry.settled = true;
|
|
143
|
+
this.#detach(entry);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
image.catch(() => {});
|
|
148
|
+
entry = {
|
|
149
|
+
key,
|
|
150
|
+
controller,
|
|
151
|
+
image,
|
|
152
|
+
refCount: 0,
|
|
153
|
+
settled: false,
|
|
154
|
+
detached: false
|
|
155
|
+
};
|
|
156
|
+
return entry;
|
|
157
|
+
}
|
|
158
|
+
#release(entry) {
|
|
159
|
+
entry.refCount--;
|
|
160
|
+
if (entry.refCount > 0) return;
|
|
161
|
+
if (!entry.settled) {
|
|
162
|
+
entry.controller.abort();
|
|
163
|
+
this.#detach(entry);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (entry.detached || this.#retainMs === 0) {
|
|
167
|
+
this.#dispose(entry);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (this.#retainMs === Number.POSITIVE_INFINITY) return;
|
|
171
|
+
entry.timer = setTimeout(() => {
|
|
172
|
+
entry.timer = void 0;
|
|
173
|
+
this.#dispose(entry);
|
|
174
|
+
}, this.#retainMs);
|
|
175
|
+
}
|
|
176
|
+
#detach(entry) {
|
|
177
|
+
entry.detached = true;
|
|
178
|
+
if (this.#entries.get(entry.key) === entry) this.#entries.delete(entry.key);
|
|
179
|
+
if (entry.refCount === 0) this.#dispose(entry);
|
|
180
|
+
}
|
|
181
|
+
#dispose(entry) {
|
|
182
|
+
if (entry.timer !== void 0) {
|
|
183
|
+
clearTimeout(entry.timer);
|
|
184
|
+
entry.timer = void 0;
|
|
185
|
+
}
|
|
186
|
+
if (this.#entries.get(entry.key) === entry) this.#entries.delete(entry.key);
|
|
187
|
+
if (entry.loaded) {
|
|
188
|
+
revokeImageUrl(entry.loaded);
|
|
189
|
+
entry.loaded = void 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
/** The cache shared by elements that use the global configuration. */
|
|
194
|
+
const imageCache = new ImageCache(3e4);
|
|
195
|
+
let globalConfig = {};
|
|
196
|
+
/**
|
|
197
|
+
* Replaces the global configuration and clears the cache.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* configure({
|
|
202
|
+
* headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
|
|
203
|
+
* });
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
function configure(config) {
|
|
207
|
+
globalConfig = { ...config };
|
|
208
|
+
imageCache.setRetainMs(config.cache?.retainMs ?? 3e4);
|
|
209
|
+
imageCache.detachAll();
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Clears the shared cache, for example on logout. Images on screen stay visible; new
|
|
213
|
+
* elements fetch again.
|
|
214
|
+
*/
|
|
215
|
+
function clearCache() {
|
|
216
|
+
imageCache.detachAll();
|
|
217
|
+
}
|
|
218
|
+
/** Fetches `src` with the given headers and resolves to the response body. */
|
|
219
|
+
async function defaultFetch(src, headers, signal) {
|
|
220
|
+
const init = typeof headers === "function" ? await headers() : headers;
|
|
221
|
+
const response = await fetch(src, {
|
|
222
|
+
headers: init,
|
|
223
|
+
signal,
|
|
224
|
+
credentials: "same-origin"
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok) throw new AuthenticatedImageError(`Request failed with status ${response.status}: ${src}`, {
|
|
227
|
+
src,
|
|
228
|
+
status: response.status
|
|
229
|
+
});
|
|
230
|
+
return response.blob();
|
|
231
|
+
}
|
|
232
|
+
function fromFetcher(fetcher) {
|
|
233
|
+
return (src, signal) => fetcher(src, { signal });
|
|
234
|
+
}
|
|
235
|
+
function fromHeaders(headers) {
|
|
236
|
+
return (src, signal) => defaultFetch(src, headers, signal);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Picks the loader for an element. Precedence: element `fetcher`, element `headers`, global
|
|
240
|
+
* `fetcher`, global `headers`, then a plain fetch.
|
|
241
|
+
*/
|
|
242
|
+
function resolveLoader(overrides) {
|
|
243
|
+
if (overrides.fetcher) return {
|
|
244
|
+
load: fromFetcher(overrides.fetcher),
|
|
245
|
+
cacheable: false
|
|
246
|
+
};
|
|
247
|
+
if (overrides.headers !== void 0) return {
|
|
248
|
+
load: fromHeaders(overrides.headers),
|
|
249
|
+
cacheable: false
|
|
250
|
+
};
|
|
251
|
+
if (globalConfig.fetcher) return {
|
|
252
|
+
load: fromFetcher(globalConfig.fetcher),
|
|
253
|
+
cacheable: true
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
load: fromHeaders(globalConfig.headers),
|
|
257
|
+
cacheable: true
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// src/lazy.ts
|
|
261
|
+
/** How far outside the viewport a lazy image starts loading. */
|
|
262
|
+
const LAZY_ROOT_MARGIN = "200px";
|
|
263
|
+
const callbacks = /* @__PURE__ */ new WeakMap();
|
|
264
|
+
let observer;
|
|
265
|
+
function getObserver() {
|
|
266
|
+
observer ??= new IntersectionObserver((entries) => {
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
if (!entry.isIntersecting) continue;
|
|
269
|
+
const onNear = callbacks.get(entry.target);
|
|
270
|
+
if (!onNear) continue;
|
|
271
|
+
unobserveLazy(entry.target);
|
|
272
|
+
onNear();
|
|
273
|
+
}
|
|
274
|
+
}, { rootMargin: LAZY_ROOT_MARGIN });
|
|
275
|
+
return observer;
|
|
276
|
+
}
|
|
277
|
+
/** Calls `onNear` once when `element` comes within {@link LAZY_ROOT_MARGIN} of the viewport. */
|
|
278
|
+
function observeLazy(element, onNear) {
|
|
279
|
+
callbacks.set(element, onNear);
|
|
280
|
+
getObserver().observe(element);
|
|
281
|
+
}
|
|
282
|
+
/** Cancels a pending {@link observeLazy} callback. */
|
|
283
|
+
function unobserveLazy(element) {
|
|
284
|
+
callbacks.delete(element);
|
|
285
|
+
observer?.unobserve(element);
|
|
286
|
+
}
|
|
287
|
+
// src/lazy-controller.ts
|
|
288
|
+
/**
|
|
289
|
+
* Runs a callback once the host comes near the viewport, and cancels it when the host
|
|
290
|
+
* disconnects.
|
|
291
|
+
*/
|
|
292
|
+
var LazyController = class {
|
|
293
|
+
#host;
|
|
294
|
+
#waiting = false;
|
|
295
|
+
constructor(host) {
|
|
296
|
+
this.#host = host;
|
|
297
|
+
host.addController(this);
|
|
298
|
+
}
|
|
299
|
+
/** Whether a callback is waiting for the host to come near the viewport. */
|
|
300
|
+
get waiting() {
|
|
301
|
+
return this.#waiting;
|
|
302
|
+
}
|
|
303
|
+
/** Calls `callback` once the host comes near the viewport. Replaces a pending callback. */
|
|
304
|
+
whenVisible(callback) {
|
|
305
|
+
this.#waiting = true;
|
|
306
|
+
observeLazy(this.#host, () => {
|
|
307
|
+
this.#waiting = false;
|
|
308
|
+
this.#host.requestUpdate();
|
|
309
|
+
callback();
|
|
310
|
+
});
|
|
311
|
+
this.#host.requestUpdate();
|
|
312
|
+
}
|
|
313
|
+
/** Cancels a pending callback. */
|
|
314
|
+
cancel() {
|
|
315
|
+
if (!this.#waiting) return;
|
|
316
|
+
this.#waiting = false;
|
|
317
|
+
unobserveLazy(this.#host);
|
|
318
|
+
this.#host.requestUpdate();
|
|
319
|
+
}
|
|
320
|
+
hostDisconnected() {
|
|
321
|
+
this.cancel();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
// src/image-loader-controller.ts
|
|
325
|
+
/**
|
|
326
|
+
* Loads an image for its host and owns everything the load holds: the request, the cache lease
|
|
327
|
+
* and the object URL. Starting a new load or disconnecting the host releases them, and results
|
|
328
|
+
* that arrive after that are dropped.
|
|
329
|
+
*/
|
|
330
|
+
var ImageLoaderController = class {
|
|
331
|
+
#host;
|
|
332
|
+
#options;
|
|
333
|
+
#lazy;
|
|
334
|
+
#src;
|
|
335
|
+
#lazyLoad;
|
|
336
|
+
#status;
|
|
337
|
+
#url;
|
|
338
|
+
#error;
|
|
339
|
+
#generation;
|
|
340
|
+
#needsStart;
|
|
341
|
+
#retried;
|
|
342
|
+
#controller;
|
|
343
|
+
#lease;
|
|
344
|
+
#ownedImage;
|
|
345
|
+
#image;
|
|
346
|
+
constructor(host, options) {
|
|
347
|
+
this.#src = "";
|
|
348
|
+
this.#lazyLoad = false;
|
|
349
|
+
this.#status = "idle";
|
|
350
|
+
this.#generation = 0;
|
|
351
|
+
this.#needsStart = true;
|
|
352
|
+
this.#retried = false;
|
|
353
|
+
this.handleImageLoad = () => {
|
|
354
|
+
if (!this.#image) return;
|
|
355
|
+
this.#status = "loaded";
|
|
356
|
+
this.#host.requestUpdate();
|
|
357
|
+
this.#options.onLoad();
|
|
358
|
+
};
|
|
359
|
+
this.handleImageError = () => {
|
|
360
|
+
if (!this.#image) return;
|
|
361
|
+
const src = this.#src;
|
|
362
|
+
this.#lease?.detach();
|
|
363
|
+
if (!this.#image.isObjectUrl && !this.#retried) {
|
|
364
|
+
this.#retried = true;
|
|
365
|
+
const generation = this.#generation;
|
|
366
|
+
this.#releaseResources();
|
|
367
|
+
this.#host.requestUpdate();
|
|
368
|
+
this.#host.updateComplete.then(() => this.#load(generation));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.#fail(new AuthenticatedImageError(`Failed to decode image: ${src}`, { src }));
|
|
372
|
+
};
|
|
373
|
+
this.#host = host;
|
|
374
|
+
this.#options = options;
|
|
375
|
+
this.#lazy = new LazyController(host);
|
|
376
|
+
host.addController(this);
|
|
377
|
+
}
|
|
378
|
+
get status() {
|
|
379
|
+
return this.#status;
|
|
380
|
+
}
|
|
381
|
+
/** URL for the inner `<img>`, set once the loader resolves. */
|
|
382
|
+
get url() {
|
|
383
|
+
return this.#url;
|
|
384
|
+
}
|
|
385
|
+
/** Why the last load failed, or `undefined`. */
|
|
386
|
+
get error() {
|
|
387
|
+
return this.#error;
|
|
388
|
+
}
|
|
389
|
+
/** Whether the image is loading or waiting to be lazily loaded. */
|
|
390
|
+
get busy() {
|
|
391
|
+
return this.#status === "loading" || this.#lazy.waiting;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Loads `src` on the host's next update and cancels the current load. With `lazy`, the request
|
|
395
|
+
* waits until the host comes near the viewport.
|
|
396
|
+
*/
|
|
397
|
+
load(src, { lazy = false } = {}) {
|
|
398
|
+
this.#src = src;
|
|
399
|
+
this.#lazyLoad = lazy;
|
|
400
|
+
this.#needsStart = true;
|
|
401
|
+
this.#host.requestUpdate();
|
|
402
|
+
}
|
|
403
|
+
/** Loads the image again. A shared cached copy is skipped. */
|
|
404
|
+
reload() {
|
|
405
|
+
this.#lease?.detach();
|
|
406
|
+
if (!this.#host.isConnected) {
|
|
407
|
+
this.#needsStart = true;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
this.#start();
|
|
411
|
+
}
|
|
412
|
+
hostConnected() {
|
|
413
|
+
this.#needsStart = true;
|
|
414
|
+
this.#host.requestUpdate();
|
|
415
|
+
}
|
|
416
|
+
hostUpdate() {
|
|
417
|
+
if (this.#needsStart && this.#host.isConnected) {
|
|
418
|
+
this.#needsStart = false;
|
|
419
|
+
this.#start();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
hostDisconnected() {
|
|
423
|
+
this.#cleanup();
|
|
424
|
+
this.#status = "idle";
|
|
425
|
+
this.#host.requestUpdate();
|
|
426
|
+
}
|
|
427
|
+
#start() {
|
|
428
|
+
this.#cleanup();
|
|
429
|
+
this.#error = void 0;
|
|
430
|
+
this.#retried = false;
|
|
431
|
+
this.#status = "idle";
|
|
432
|
+
this.#host.requestUpdate();
|
|
433
|
+
if (!this.#src) return;
|
|
434
|
+
const generation = this.#generation;
|
|
435
|
+
if (this.#lazyLoad) {
|
|
436
|
+
this.#lazy.whenVisible(() => void this.#load(generation));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.#load(generation);
|
|
440
|
+
}
|
|
441
|
+
async #load(generation) {
|
|
442
|
+
if (generation !== this.#generation) return;
|
|
443
|
+
const src = this.#src;
|
|
444
|
+
const loader = resolveLoader(this.#options.overrides());
|
|
445
|
+
this.#status = "loading";
|
|
446
|
+
this.#host.requestUpdate();
|
|
447
|
+
let image;
|
|
448
|
+
try {
|
|
449
|
+
if (loader.cacheable) {
|
|
450
|
+
const lease = imageCache.acquire(src, (signal) => loader.load(src, signal));
|
|
451
|
+
this.#lease = lease;
|
|
452
|
+
image = await lease.image;
|
|
453
|
+
} else {
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
this.#controller = controller;
|
|
456
|
+
image = toImageUrl(await loader.load(src, controller.signal));
|
|
457
|
+
if (generation !== this.#generation) {
|
|
458
|
+
revokeImageUrl(image);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this.#ownedImage = image;
|
|
462
|
+
}
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (generation === this.#generation) this.#fail(toAuthenticatedImageError(src, error));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (generation !== this.#generation) return;
|
|
468
|
+
this.#image = image;
|
|
469
|
+
this.#url = image.url;
|
|
470
|
+
this.#host.requestUpdate();
|
|
471
|
+
}
|
|
472
|
+
#fail(error) {
|
|
473
|
+
this.#releaseResources();
|
|
474
|
+
this.#error = error;
|
|
475
|
+
this.#status = "error";
|
|
476
|
+
this.#host.requestUpdate();
|
|
477
|
+
this.#options.onError(error);
|
|
478
|
+
}
|
|
479
|
+
#releaseResources() {
|
|
480
|
+
this.#controller?.abort();
|
|
481
|
+
this.#controller = void 0;
|
|
482
|
+
this.#lease?.release();
|
|
483
|
+
this.#lease = void 0;
|
|
484
|
+
if (this.#ownedImage) {
|
|
485
|
+
revokeImageUrl(this.#ownedImage);
|
|
486
|
+
this.#ownedImage = void 0;
|
|
487
|
+
}
|
|
488
|
+
this.#image = void 0;
|
|
489
|
+
this.#url = void 0;
|
|
490
|
+
}
|
|
491
|
+
#cleanup() {
|
|
492
|
+
this.#generation++;
|
|
493
|
+
this.#lazy.cancel();
|
|
494
|
+
this.#releaseResources();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
// \0@oxc-project+runtime@0.155.0/helpers/esm/decorate.js
|
|
498
|
+
function __decorate(decorators, target, key, desc) {
|
|
499
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
500
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
501
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
502
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
503
|
+
}
|
|
504
|
+
// src/authenticated-image.ts
|
|
505
|
+
const RELOAD_TRIGGERS = ["src", "loading"];
|
|
506
|
+
/**
|
|
507
|
+
* An `<img>` that loads its image with `fetch`, so the request can carry an `Authorization`
|
|
508
|
+
* header.
|
|
509
|
+
*
|
|
510
|
+
* @fires load - The image has been decoded and rendered.
|
|
511
|
+
* @fires error - Loading or decoding failed. `event.detail.error` describes why.
|
|
512
|
+
* @slot loading - Shown while the image is loading or waiting to be lazily loaded.
|
|
513
|
+
* @slot error - Shown when the image failed to load.
|
|
514
|
+
* @csspart img - The inner `<img>`.
|
|
515
|
+
* @cssprop --authenticated-image-object-fit - `object-fit` of the inner image. Defaults to `fill`.
|
|
516
|
+
*/
|
|
517
|
+
var AuthenticatedImageElement = class extends LitElement {
|
|
518
|
+
constructor(..._args) {
|
|
519
|
+
super(..._args);
|
|
520
|
+
this.src = "";
|
|
521
|
+
this.alt = "";
|
|
522
|
+
this.loading = "eager";
|
|
523
|
+
this.decoding = "async";
|
|
524
|
+
this._loader = new ImageLoaderController(this, {
|
|
525
|
+
overrides: () => this,
|
|
526
|
+
onLoad: () => this.dispatchEvent(new Event("load")),
|
|
527
|
+
onError: (error) => this.dispatchEvent(new CustomEvent("error", { detail: { error } }))
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
static {
|
|
531
|
+
this.styles = styles;
|
|
532
|
+
}
|
|
533
|
+
/** Current loading state. Mirrored to the `status` attribute. */
|
|
534
|
+
get status() {
|
|
535
|
+
return this._loader.status;
|
|
536
|
+
}
|
|
537
|
+
/** Why the last load failed, or `undefined`. */
|
|
538
|
+
get error() {
|
|
539
|
+
return this._loader.error;
|
|
540
|
+
}
|
|
541
|
+
/** Loads the image again. Elements that use the shared cache skip the cached copy. */
|
|
542
|
+
reload() {
|
|
543
|
+
this._loader.reload();
|
|
544
|
+
}
|
|
545
|
+
willUpdate(changed) {
|
|
546
|
+
if (RELOAD_TRIGGERS.some((key) => changed.has(key))) this._loader.load(this.src, { lazy: this.loading === "lazy" });
|
|
547
|
+
}
|
|
548
|
+
updated() {
|
|
549
|
+
this.setAttribute("status", this._loader.status);
|
|
550
|
+
if (this._loader.busy) this.setAttribute("aria-busy", "true");
|
|
551
|
+
else this.removeAttribute("aria-busy");
|
|
552
|
+
}
|
|
553
|
+
render() {
|
|
554
|
+
const loaded = this._loader.status === "loaded";
|
|
555
|
+
return html`<img
|
|
556
|
+
part="img"
|
|
557
|
+
?hidden=${!loaded}
|
|
558
|
+
src=${this._loader.url ?? nothing}
|
|
559
|
+
alt=${loaded ? this.alt : ""}
|
|
560
|
+
decoding=${this.decoding}
|
|
561
|
+
width=${this.width ?? nothing}
|
|
562
|
+
height=${this.height ?? nothing}
|
|
563
|
+
@load=${this._loader.handleImageLoad}
|
|
564
|
+
@error=${this._loader.handleImageError}
|
|
565
|
+
/><slot name="loading" ?hidden=${!this._loader.busy}></slot
|
|
566
|
+
><slot name="error" ?hidden=${this._loader.status !== "error"}></slot>`;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
__decorate([property()], AuthenticatedImageElement.prototype, "src", void 0);
|
|
570
|
+
__decorate([property()], AuthenticatedImageElement.prototype, "alt", void 0);
|
|
571
|
+
__decorate([property()], AuthenticatedImageElement.prototype, "loading", void 0);
|
|
572
|
+
__decorate([property()], AuthenticatedImageElement.prototype, "decoding", void 0);
|
|
573
|
+
__decorate([property({ type: Number })], AuthenticatedImageElement.prototype, "width", void 0);
|
|
574
|
+
__decorate([property({ type: Number })], AuthenticatedImageElement.prototype, "height", void 0);
|
|
575
|
+
__decorate([property({ attribute: false })], AuthenticatedImageElement.prototype, "fetcher", void 0);
|
|
576
|
+
__decorate([property({ attribute: false })], AuthenticatedImageElement.prototype, "headers", void 0);
|
|
577
|
+
// src/index.ts
|
|
578
|
+
if (!customElements.get("authenticated-image")) customElements.define("authenticated-image", AuthenticatedImageElement);
|
|
579
|
+
export { AuthenticatedImageError as i, clearCache as n, configure as r, AuthenticatedImageElement as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,95 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gyeonghokim/authenticated-image",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A drop-in <img> for images behind Authorization headers. Web Component + React.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"web-component",
|
|
7
|
+
"custom-element",
|
|
8
|
+
"lit",
|
|
9
|
+
"image",
|
|
10
|
+
"img",
|
|
11
|
+
"authorization",
|
|
12
|
+
"bearer",
|
|
13
|
+
"jwt",
|
|
14
|
+
"blob",
|
|
15
|
+
"object-url",
|
|
16
|
+
"react",
|
|
17
|
+
"presigned-url"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/GyeongHoKim/authenticated-image#readme",
|
|
20
|
+
"bugs": "https://github.com/GyeongHoKim/authenticated-image/issues",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/GyeongHoKim/authenticated-image.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "GyeongHo Kim <me@gyeongho.dev>",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"default": "./dist/index.mjs"
|
|
32
|
+
},
|
|
33
|
+
"./react": {
|
|
34
|
+
"types": "./dist/react.d.mts",
|
|
35
|
+
"default": "./dist/react.mjs"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist"
|
|
41
|
+
],
|
|
42
|
+
"sideEffects": [
|
|
43
|
+
"./dist/*.mjs"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"packageManager": "pnpm@12.14.0",
|
|
49
|
+
"scripts": {
|
|
50
|
+
"dev": "vite",
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"typecheck:dist": "tsc -p tsconfig.dist-check.json",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"check": "biome check",
|
|
57
|
+
"format": "biome check --write",
|
|
58
|
+
"lint:package": "publint --strict",
|
|
59
|
+
"prepare": "husky"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"react": "^18 || ^19"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"react": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@biomejs/biome": "2.6.4",
|
|
71
|
+
"@commitlint/cli": "^21.2.4",
|
|
72
|
+
"@commitlint/config-conventional": "^21.2.4",
|
|
73
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
74
|
+
"@semantic-release/git": "^11.0.1",
|
|
75
|
+
"@types/node": "^26.6.6",
|
|
76
|
+
"@types/react": "^19.3.0",
|
|
77
|
+
"@types/react-dom": "^19.3.0",
|
|
78
|
+
"@vitest/browser-playwright": "^5.0.5",
|
|
79
|
+
"conventional-changelog-conventionalcommits": "^10.4.1",
|
|
80
|
+
"husky": "^9.1.7",
|
|
81
|
+
"playwright": "^1.64.1",
|
|
82
|
+
"publint": "^0.3.25",
|
|
83
|
+
"react": "^19.3.1",
|
|
84
|
+
"react-dom": "^19.3.1",
|
|
85
|
+
"semantic-release": "^25.0.9",
|
|
86
|
+
"tsdown": "~0.23.3",
|
|
87
|
+
"typescript": "^7.0.2",
|
|
88
|
+
"vite": "^8.3.5",
|
|
89
|
+
"vitest": "^5.0.5"
|
|
90
|
+
},
|
|
91
|
+
"dependencies": {
|
|
92
|
+
"@lit/react": "^1.0.8",
|
|
93
|
+
"lit": "^3.3.3"
|
|
94
|
+
}
|
|
95
|
+
}
|