@takumi-rs/image-response 0.29.2
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/README.md +73 -0
- package/package.json +37 -0
- package/src/index.ts +74 -0
- package/tests/image-response.test.tsx +13 -0
- package/tsconfig.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @takumi-rs/image-response
|
|
2
|
+
|
|
3
|
+
A universal `ImageResponse` implementation for Takumi in Next.js and other environments.
|
|
4
|
+
|
|
5
|
+
Checkout the migration guide [From Next.js ImageResponse](https://takumi.kane.tw/docs/migration/migrate-from-image-response) for more details.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @takumi-rs/image-response @takumi-rs/core @takumi-rs/helpers
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import ImageResponse from "@takumi-rs/image-response";
|
|
17
|
+
|
|
18
|
+
export function GET(request: Request) {
|
|
19
|
+
return new ImageResponse(<OgImage />, {
|
|
20
|
+
width: 1200,
|
|
21
|
+
height: 630,
|
|
22
|
+
format: "webp",
|
|
23
|
+
headers: {
|
|
24
|
+
"Cache-Control": "public, immutable, max-age=31536000",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Fonts
|
|
31
|
+
|
|
32
|
+
Takumi comes with full axis [Geist](https://vercel.com/font) and Geist Mono by default.
|
|
33
|
+
|
|
34
|
+
We have global fonts cache to avoid loading the same fonts multiple times.
|
|
35
|
+
|
|
36
|
+
If your environment supports top-level await, you can load the fonts in global scope and reuse the fonts array.
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
const fonts = [
|
|
40
|
+
{
|
|
41
|
+
name: "Inter",
|
|
42
|
+
data: await fetch("/fonts/Inter-Regular.ttf").then((res) => res.arrayBuffer()),
|
|
43
|
+
style: "normal",
|
|
44
|
+
weight: 400,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
new ImageResponse(<OgImage />, { fonts });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If your environment doesn't support top-level await, or just want the fonts to get garbage collected after initialization, you can load the fonts like this.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
let isFontsLoaded = false;
|
|
55
|
+
|
|
56
|
+
export function GET(request: Request) {
|
|
57
|
+
const fonts = [];
|
|
58
|
+
|
|
59
|
+
if (!isFontsLoaded) {
|
|
60
|
+
isFontsLoaded = true;
|
|
61
|
+
fonts = [
|
|
62
|
+
{
|
|
63
|
+
name: "Inter",
|
|
64
|
+
data: await fetch("/fonts/Inter-Regular.ttf").then((res) => res.arrayBuffer()),
|
|
65
|
+
style: "normal",
|
|
66
|
+
weight: 400,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return new ImageResponse(<OgImage />, { fonts });
|
|
72
|
+
}
|
|
73
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@takumi-rs/image-response",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.29.2",
|
|
5
|
+
"author": {
|
|
6
|
+
"email": "me@kane.tw",
|
|
7
|
+
"name": "Kane Wang",
|
|
8
|
+
"url": "https://kane.tw"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"url": "git+https://github.com/kane50613/takumi.git"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@takumi-rs/core": "^0.29.2",
|
|
15
|
+
"@takumi-rs/helpers": "^0.29.2"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@takumi-rs/core": "workspace:*",
|
|
19
|
+
"@takumi-rs/helpers": "workspace:*",
|
|
20
|
+
"@types/bun": "catalog:",
|
|
21
|
+
"react": "^19.1.1",
|
|
22
|
+
"react-dom": "^19.1.1",
|
|
23
|
+
"tsup": "^8.5.0",
|
|
24
|
+
"typescript": "catalog:"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup-node src/index.ts --minify --dts --format esm,cjs --no-splitting"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"require": "./dist/index.cjs",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Renderer, type RenderOptions } from "@takumi-rs/core";
|
|
2
|
+
import { fromJsx } from "@takumi-rs/helpers/jsx";
|
|
3
|
+
import type { HeadersInit } from "bun";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
const renderer = new Renderer();
|
|
7
|
+
|
|
8
|
+
const fontLoadMarker = new WeakSet<Font>();
|
|
9
|
+
|
|
10
|
+
export type Font = Parameters<typeof renderer.loadFontAsync>[0];
|
|
11
|
+
|
|
12
|
+
export type ImageResponseOptions = RenderOptions &
|
|
13
|
+
ResponseInit & {
|
|
14
|
+
headers?: HeadersInit;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
fonts?: Font[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const defaultOptions: ImageResponseOptions = {
|
|
20
|
+
width: 1200,
|
|
21
|
+
height: 630,
|
|
22
|
+
format: "webp",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function loadFonts(fonts: Font[]) {
|
|
26
|
+
const fontsToLoad = fonts.filter((font) => !fontLoadMarker.has(font));
|
|
27
|
+
|
|
28
|
+
for (const font of fontsToLoad) {
|
|
29
|
+
fontLoadMarker.add(font);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return renderer.loadFontsAsync(fontsToLoad);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createStream(component: ReactNode, options?: ImageResponseOptions) {
|
|
36
|
+
return new ReadableStream({
|
|
37
|
+
async start(controller) {
|
|
38
|
+
try {
|
|
39
|
+
if (options?.fonts) await loadFonts(options.fonts);
|
|
40
|
+
|
|
41
|
+
const node = await fromJsx(component);
|
|
42
|
+
const image = await renderer.renderAsync(
|
|
43
|
+
node,
|
|
44
|
+
options ?? defaultOptions,
|
|
45
|
+
options?.signal,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
controller.enqueue(image);
|
|
49
|
+
controller.close();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
controller.error(error);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default class ImageResponse extends Response {
|
|
58
|
+
constructor(component: ReactNode, options?: ImageResponseOptions) {
|
|
59
|
+
const stream = createStream(component, options);
|
|
60
|
+
const headers = new Headers(options?.headers);
|
|
61
|
+
|
|
62
|
+
headers.set("Content-Type", "image/webp");
|
|
63
|
+
|
|
64
|
+
if (!headers.get("cache-control")) {
|
|
65
|
+
headers.set("cache-control", "public, max-age=0, must-revalidate");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
super(stream, {
|
|
69
|
+
status: options?.status,
|
|
70
|
+
statusText: options?.statusText,
|
|
71
|
+
headers,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ImageResponse from "../src";
|
|
3
|
+
|
|
4
|
+
describe("ImageResponse", () => {
|
|
5
|
+
test("should not crash", async () => {
|
|
6
|
+
const response = new ImageResponse(<div>Hello</div>);
|
|
7
|
+
|
|
8
|
+
expect(response.status).toBe(200);
|
|
9
|
+
expect(response.headers.get("content-type")).toBe("image/webp");
|
|
10
|
+
|
|
11
|
+
expect(await response.arrayBuffer()).toBeDefined();
|
|
12
|
+
});
|
|
13
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|