@uxf/resizer 11.125.0 → 11.129.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.
Files changed (39) hide show
  1. package/.resizer-config.json +4 -4
  2. package/README.md +35 -25
  3. package/bin/uxf-resizer.js +20 -20
  4. package/bin/uxf-resizer.ts +19 -17
  5. package/package.json +4 -9
  6. package/src/handler.d.ts +10 -0
  7. package/src/handler.js +90 -0
  8. package/src/handler.test.js +65 -0
  9. package/src/handler.test.ts +79 -0
  10. package/src/handler.ts +121 -0
  11. package/src/utils/get-source-filename.d.ts +1 -1
  12. package/src/utils/get-source-filename.js +14 -15
  13. package/src/utils/get-source-filename.test.js +21 -0
  14. package/src/utils/get-source-filename.test.ts +33 -0
  15. package/src/utils/get-source-filename.ts +16 -16
  16. package/src/utils/match-route.d.ts +13 -0
  17. package/src/utils/match-route.js +38 -0
  18. package/src/utils/match-route.test.d.ts +1 -0
  19. package/src/utils/match-route.test.js +79 -0
  20. package/src/utils/match-route.test.ts +94 -0
  21. package/src/utils/match-route.ts +41 -0
  22. package/src/utils/tools.d.ts +10 -10
  23. package/src/utils/tools.js +2 -2
  24. package/src/utils/tools.test.d.ts +1 -0
  25. package/src/utils/tools.test.js +46 -0
  26. package/src/utils/tools.test.ts +48 -0
  27. package/src/utils/tools.ts +12 -11
  28. package/src/middleware.d.ts +0 -5
  29. package/src/middleware.js +0 -92
  30. package/src/middleware.ts +0 -73
  31. package/src/utils/parse-http-source.d.ts +0 -7
  32. package/src/utils/parse-http-source.js +0 -7
  33. package/src/utils/parse-http-source.test.js +0 -26
  34. package/src/utils/parse-http-source.test.ts +0 -25
  35. package/src/utils/parse-http-source.ts +0 -9
  36. package/src/utils/repair-params.d.ts +0 -8
  37. package/src/utils/repair-params.js +0 -19
  38. package/src/utils/repair-params.ts +0 -15
  39. /package/src/{utils/parse-http-source.test.d.ts → handler.test.d.ts} +0 -0
@@ -1,14 +1,14 @@
1
1
  [
2
2
  {
3
- "route": "/generated/:namespace/:p1/:p2/:filename([a-f0-9\\-]+)_:width(\\d+)_:height(\\d+)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:extension.:toFormat",
4
- "source": "/Users/vejvis/UXF/projects/resizer/example-upload/:namespace/:p1/:p2/:filename.:extension"
3
+ "route": "/generated/:namespace/:p1/:p2/:filename([a-f0-9\\-]+)_:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([0-9a-ft]+)_:trim([a-z]+)_:quality(\\d+|x)_:extension.:toFormat",
4
+ "source": "/Users/mac/Develop/PhpStormProjects/UXF/fe-monorepo/packages/resizer/example-upload/:namespace/:p1/:p2/:filename.:extension"
5
5
  },
6
6
  {
7
7
  "route": "/generated/:namespace/:p1/:p2/:filename([a-f0-9\\-]+)_:width(\\d+)_:height(\\d+)_:fit([a-z]+).:extension",
8
- "source": "/Users/vejvis/UXF/projects/resizer/example-upload/:namespace/:p1/:p2/:filename.:extension"
8
+ "source": "/Users/mac/Develop/PhpStormProjects/UXF/fe-monorepo/packages/resizer/example-upload/:namespace/:p1/:p2/:filename.:extension"
9
9
  },
10
10
  {
11
- "route": "/generated/static/:width(\\d+)_:height(\\d+)/:filename(*).:extension.:toFormat",
11
+ "route": "/generated/static/:width(\\d+)_:height(\\d+)/:filename(.*).:extension.:toFormat",
12
12
  "source": "https://www.uxf.cz/:filename+.:extension"
13
13
  }
14
14
  ]
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@uxf/resizer)](https://www.npmjs.com/package/@uxf/resizer)
4
4
 
5
- On-the-fly image resizing that serves the `/generated/...` URLs produced by `resizerImageUrl` (`@uxf/core`). It ships Next.js App Router route handlers for local development and a standalone Express server (`uxf-resizer`) for self-hosting. Resizing is done with [`sharp`](https://sharp.pixelplumbing.com/).
5
+ On-the-fly image resizing that serves the `/generated/...` URLs produced by `resizerImageUrl` (`@uxf/core`). It ships Next.js App Router route handlers for local development and a standalone HTTP server (`uxf-resizer`, built on [Hono](https://hono.dev/)) for self-hosting. Resizing is done with [`sharp`](https://sharp.pixelplumbing.com/).
6
6
 
7
7
  ## When to use
8
8
 
@@ -10,7 +10,7 @@ This package **serves and generates** resized images. It does **not** build imag
10
10
 
11
11
  - **Production** — a shared/global resizer service handles the `/generated/...` requests; you typically do not run this package there.
12
12
  - **Local development** — mount `ProxyGET` / `StaticGET` as Next.js route handlers so `/generated/...` URLs resolve during `next dev`.
13
- - **Self-hosting** — run the `uxf-resizer` CLI (Express server) driven by a config file.
13
+ - **Self-hosting** — run the `uxf-resizer` CLI (Hono server on Node) driven by a config file.
14
14
 
15
15
  Both entry points read the same URL segments (width, height, fit, position, background, trim, quality, target format) and pipe the source image through `sharp`. Generated files are cached on disk and re-served on subsequent requests.
16
16
 
@@ -20,12 +20,14 @@ Both entry points read the same URL segments (width, height, fit, position, back
20
20
  yarn add @uxf/resizer --dev
21
21
  ```
22
22
 
23
- The only runtime dependency is `sharp`. The standalone server additionally needs `express`, `path-to-regexp`, `process`, and `yargs` — they are **not** installed transitively, so add them yourself when you use the CLI:
23
+ The only runtime dependency is `sharp`. The standalone server additionally needs `hono`, `@hono/node-server`, and `yargs` — they are **not** installed transitively, so add them yourself when you use the CLI:
24
24
 
25
25
  ```
26
- yarn add @uxf/resizer express path-to-regexp process yargs --dev
26
+ yarn add @uxf/resizer hono @hono/node-server yargs --dev
27
27
  ```
28
28
 
29
+ Node `>= 24` is required (route matching uses the built-in `URLPattern`).
30
+
29
31
  ## Quick start
30
32
 
31
33
  Next.js App Router route handlers for local development. Two handlers are exported:
@@ -72,10 +74,10 @@ NEXT_PUBLIC_API_URL=https://uxf.cz # ProxyGET source (fetches <API
72
74
 
73
75
  Exported from the package root (`@uxf/resizer`):
74
76
 
75
- | Export | Signature | Description |
76
- |--------|-----------|-------------|
77
- | `ProxyGET` | `(request: Request) => Promise<Response>` | Next.js route handler. Matches `/generated/:namespace/:p1/:p2/:filename_..._:extension.:toFormat`, fetches the source from `${NEXT_PUBLIC_API_URL}/upload/:namespace/:p1/:p2/:filename.:extension`, resizes, caches under the process CWD, and returns the image. |
78
- | `StaticGET` | `(request: Request) => Promise<Response>` | Next.js route handler. Matches `/generated/static/..._:quality/:version/:filename.:extension.:toFormat`. Reads local `public/:filename.:extension`; for `_next/static/media/*` imports it fetches from `${NEXT_PUBLIC_FRONTEND_URL}`. |
77
+ | Export | Signature | Description |
78
+ | ----------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
79
+ | `ProxyGET` | `(request: Request) => Promise<Response>` | Next.js route handler. Matches `/generated/:namespace/:p1/:p2/:filename_..._:extension.:toFormat`, fetches the source from `${NEXT_PUBLIC_API_URL}/upload/:namespace/:p1/:p2/:filename.:extension`, resizes, caches under the process CWD, and returns the image. |
80
+ | `StaticGET` | `(request: Request) => Promise<Response>` | Next.js route handler. Matches `/generated/static/..._:quality/:version/:filename.:extension.:toFormat`. Reads local `public/:filename.:extension`; for `_next/static/media/*` imports it fetches from `${NEXT_PUBLIC_FRONTEND_URL}`. |
79
81
 
80
82
  Command-line server:
81
83
 
@@ -83,24 +85,24 @@ Command-line server:
83
85
  uxf-resizer
84
86
  ```
85
87
 
86
- Starts an Express server on port `3000` that applies the routes from the configuration (see below). Requires the extra dependencies listed under Installation.
88
+ Starts a Hono server (via `@hono/node-server`) on port `3000` that applies the routes from the configuration (see below). Requires the extra dependencies listed under Installation. Responses carry `Content-Type`, `Content-Length` and `Last-Modified`; the server does not emit `ETag` and does not handle `Range` or conditional requests — put a cache/CDN in front of it if you need those.
87
89
 
88
90
  ## Route parameters
89
91
 
90
92
  The URL segments understood by every route. They are produced by `resizerImageUrl` (`@uxf/core`) and mapped onto [`sharp`](https://sharp.pixelplumbing.com/) options.
91
93
 
92
- | Parameter | Values | Default (falls back to sharp) |
93
- |------------|--------------------------------------------|----------------------------------------|
94
- | width | `number` or `x` (auto) | `undefined` (auto) |
95
- | height | `number` or `x` (auto) | `undefined` (auto) |
96
- | fit | fit code (see below) | `cover` |
97
- | position | position code (see below) | sharp default (`centre`) |
98
- | background | hex (e.g. `FFFFFF`) or `t` (transparent) | `#<hex>` |
99
- | trim | `number` (threshold) or `nt` (no trim) | no trim |
100
- | quality | `number` (1–100) or `x` (auto) | sharp default (avif: 50, others: 80) |
101
- | toFormat | `webp`, `png`, `avif`, `jpg`, `svg` | `jpg` |
94
+ | Parameter | Values | Default (falls back to sharp) |
95
+ | ---------- | ---------------------------------------- | ------------------------------------ |
96
+ | width | `number` or `x` (auto) | `undefined` (auto) |
97
+ | height | `number` or `x` (auto) | `undefined` (auto) |
98
+ | fit | fit code (see below) | `cover` |
99
+ | position | position code (see below) | sharp default (`centre`) |
100
+ | background | hex (e.g. `FFFFFF`) or `t` (transparent) | `t` when the route has no segment |
101
+ | trim | `number` (threshold) or `nt` (no trim) | no trim |
102
+ | quality | `number` (1–100) or `x` (auto) | sharp default (avif: 50, others: 80) |
103
+ | toFormat | `webp`, `png`, `avif`, `jpg`, `svg` | `jpg` |
102
104
 
103
- `svg` is a passthrough — the source file is copied unchanged, without resizing.
105
+ `svg` is a passthrough — the source file is copied unchanged, without resizing. A route does not have to capture every parameter: a missing `fit`, `position`, `background`, `trim` or `quality` falls back to the default in the table.
104
106
 
105
107
  ### Fit codes
106
108
 
@@ -128,12 +130,17 @@ The URL segments understood by every route. They are produced by `resizerImageUr
128
130
 
129
131
  The `uxf-resizer` CLI reads its config from `.resizer-config.json` in the working directory, or from the `UXF_RESIZER_CONFIG` environment variable (an inline JSON string, which takes precedence). Generated files are written to `UXF_RESIZER_GENERATE_PATH` (defaults to the process CWD).
130
132
 
131
- The config is an array of `{ route, source }` objects. `route` is a [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) pattern that captures the resize parameters; `source` is the original file location (a local path or an `http(s)` URL, which is fetched). `source` may reference any captured segment, including the domain as a parameter (e.g. `https://:url/...`).
133
+ The config is an array of `{ route, source }` objects.
134
+
135
+ - `route` is a [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API) pathname pattern (Node's built-in implementation; the syntax is `path-to-regexp` v6 compatible): `:name`, `:name(regexp)`, several parameters per segment (`:width(\d+)_:height(\d+)`), `:name(.*)` to capture across slashes. Matching is case-insensitive and captured values are percent-decoded. The first matching route wins.
136
+ - `source` is the original file location (a local path or an `http(s)` URL, which is fetched). Every `:name` is replaced by the captured value; a `+`, `*` or `?` suffix is accepted and ignored (`:filename+` and `:filename` are equivalent). The domain may itself be a parameter (`https://:url/...`); a port such as `localhost:3000` is left alone.
137
+
138
+ The legacy Express form `:filename(*)` is still accepted and rewritten to `:filename(.*)` with a startup warning — update your configs.
132
139
 
133
140
  ```json
134
141
  [
135
142
  {
136
- "route": "/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)/:version/:filename(*).:extension.:toFormat",
143
+ "route": "/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)/:version/:filename(.*).:extension.:toFormat",
137
144
  "source": "https://static.example.dev/:filename+.:extension"
138
145
  },
139
146
  {
@@ -152,7 +159,7 @@ Deprecated — use the global resizer for all projects instead.
152
159
  ```json
153
160
  [
154
161
  {
155
- "route": "/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)/:version/:filename(*).:extension.:toFormat",
162
+ "route": "/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)/:version/:filename(.*).:extension.:toFormat",
156
163
  "source": "https://uxf-base.uxf.dev/:filename+.:extension"
157
164
  },
158
165
  {
@@ -165,13 +172,16 @@ Deprecated — use the global resizer for all projects instead.
165
172
  ## Gotchas
166
173
 
167
174
  - **URLs are built elsewhere.** Encode the parameters with `resizerImageUrl` from `@uxf/core/utils/resizer`; this package only serves the resulting paths.
168
- - **CLI dependencies are not transitive.** `express`, `path-to-regexp`, `process`, and `yargs` are dev dependencies of this package, so a consumer running `uxf-resizer` must install them explicitly.
175
+ - **CLI dependencies are not transitive.** `hono`, `@hono/node-server`, and `yargs` are dev dependencies of this package, so a consumer running `uxf-resizer` must install them explicitly.
176
+ - **Parameters are validated only by the route regexps.** A value that passes the route but is not a known code (an unknown `fit`, a non-numeric `width`) falls back to sharp defaults or makes sharp throw, which is answered with `404`.
169
177
  - **Generated files are cached on disk.** Once a variant exists under the output path it is served as-is; delete the cached file to force regeneration.
170
- - **The server listens on a fixed port `3000`** and an empty or invalid configuration throws on startup.
178
+ - **The server listens on a fixed port `3000`** and an empty or invalid configuration (including a route `URLPattern` cannot parse) throws on startup. `SIGINT`/`SIGTERM` close the server.
171
179
  - **`quality` defaults to sharp's own defaults** when set to `x` (avif ≈ 50, others ≈ 80). Values are expected in the `1–100` range.
172
180
 
173
181
  ## Links
174
182
 
175
183
  - [npm: @uxf/resizer](https://www.npmjs.com/package/@uxf/resizer)
176
184
  - [sharp documentation](https://sharp.pixelplumbing.com/)
185
+ - [Hono](https://hono.dev/) and [`@hono/node-server`](https://github.com/honojs/node-server)
186
+ - [URLPattern API](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)
177
187
  - URL builder: `resizerImageUrl` in `@uxf/core/utils/resizer`
@@ -37,11 +37,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- const express_1 = __importDefault(require("express"));
40
+ const node_server_1 = require("@hono/node-server");
41
41
  const fs = __importStar(require("fs"));
42
- const process_1 = require("process");
42
+ const hono_1 = require("hono");
43
+ const node_process_1 = require("node:process");
43
44
  const yargs_1 = __importDefault(require("yargs"));
44
- const middleware_1 = require("../src/middleware");
45
+ const handler_1 = require("../src/handler");
46
+ const PORT = 3000;
45
47
  const uxfResizer = async () => {
46
48
  var _a;
47
49
  (0, yargs_1.default)()
@@ -57,31 +59,29 @@ Environment variables:
57
59
  .strict(false)
58
60
  .exitProcess(false);
59
61
  try {
60
- const { help } = (0, yargs_1.default)().parse(process_1.argv.slice(2)); // TODO
62
+ const { help } = (0, yargs_1.default)().parse(node_process_1.argv.slice(2)); // TODO
61
63
  if (help) {
62
64
  return 0;
63
65
  }
64
66
  const configFilename = process.cwd() + "/.resizer-config.json";
65
- const configString = (_a = process_1.env.UXF_RESIZER_CONFIG) !== null && _a !== void 0 ? _a : (fs.existsSync(configFilename) ? fs.readFileSync(configFilename).toString() : "[]");
67
+ const configString = (_a = node_process_1.env.UXF_RESIZER_CONFIG) !== null && _a !== void 0 ? _a : (fs.existsSync(configFilename) ? fs.readFileSync(configFilename).toString() : "[]");
66
68
  const config = JSON.parse(configString);
67
69
  if (!Array.isArray(config) || config.length === 0) {
68
70
  throw new Error("Invalid or empty configuration");
69
71
  }
70
- return new Promise((resolve, reject) => {
71
- try {
72
- const app = (0, express_1.default)().use((0, middleware_1.resizerMiddleware)(config));
73
- const listenedApp = app.listen(3000, () => {
74
- // eslint-disable-next-line no-console
75
- console.log("\nImage resizer app listening on port 3000");
76
- app.on("close", () => {
77
- listenedApp.close();
78
- resolve(true);
79
- });
80
- });
81
- }
82
- catch (e) {
83
- reject(e);
84
- }
72
+ const handler = (0, handler_1.createResizerHandler)(config);
73
+ const app = new hono_1.Hono();
74
+ app.get("*", (c) => handler(c.req.raw));
75
+ return new Promise((resolve) => {
76
+ const server = (0, node_server_1.serve)({ fetch: app.fetch, port: PORT }, () => {
77
+ // eslint-disable-next-line no-console
78
+ console.log(`\nImage resizer app listening on port ${PORT}`);
79
+ });
80
+ const shutdown = () => {
81
+ server.close(() => resolve(0));
82
+ };
83
+ process.once("SIGINT", shutdown);
84
+ process.once("SIGTERM", shutdown);
85
85
  });
86
86
  }
87
87
  catch (e) {
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import express from "express";
2
+ import { serve } from "@hono/node-server";
3
3
  import * as fs from "fs";
4
- import { argv, env } from "process";
4
+ import { Hono } from "hono";
5
+ import { argv, env } from "node:process";
5
6
  import cli from "yargs";
6
- import { Config, resizerMiddleware } from "../src/middleware";
7
+ import { Config, createResizerHandler } from "../src/handler";
8
+
9
+ const PORT = 3000;
7
10
 
8
11
  const uxfResizer = async () => {
9
12
  cli()
@@ -38,22 +41,21 @@ Environment variables:
38
41
  throw new Error("Invalid or empty configuration");
39
42
  }
40
43
 
41
- return new Promise((resolve, reject) => {
42
- try {
43
- const app = express().use(resizerMiddleware(config));
44
+ const handler = createResizerHandler(config);
45
+ const app = new Hono();
46
+ app.get("*", (c) => handler(c.req.raw));
44
47
 
45
- const listenedApp = app.listen(3000, () => {
46
- // eslint-disable-next-line no-console
47
- console.log("\nImage resizer app listening on port 3000");
48
+ return new Promise<number>((resolve) => {
49
+ const server = serve({ fetch: app.fetch, port: PORT }, () => {
50
+ // eslint-disable-next-line no-console
51
+ console.log(`\nImage resizer app listening on port ${PORT}`);
52
+ });
48
53
 
49
- app.on("close" as any, () => {
50
- listenedApp.close();
51
- resolve(true);
52
- });
53
- });
54
- } catch (e) {
55
- reject(e);
56
- }
54
+ const shutdown = () => {
55
+ server.close(() => resolve(0));
56
+ };
57
+ process.once("SIGINT", shutdown);
58
+ process.once("SIGTERM", shutdown);
57
59
  });
58
60
  } catch (e) {
59
61
  // eslint-disable-next-line no-console
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxf/resizer",
3
- "version": "11.125.0",
3
+ "version": "11.129.0",
4
4
  "main": "index.js",
5
5
  "description": "UXF Resizer",
6
6
  "author": "UXFans <dev@uxf.cz>",
@@ -16,17 +16,12 @@
16
16
  },
17
17
  "license": "MIT",
18
18
  "dependencies": {
19
- "sharp": "^0.34.4"
19
+ "sharp": "^0.35.4"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/express": "4.17.25",
23
- "@types/express-serve-static-core": "4.19.9",
22
+ "@hono/node-server": "2.1.1",
24
23
  "@types/node": "24",
25
- "concurrently": "10.0.4",
26
- "crypto-js": "4.2.0",
27
- "express": "4.22.2",
28
- "path-to-regexp": "6.2.2",
29
- "process": "0.11.10",
24
+ "hono": "4.13.7",
30
25
  "tsx": "4.23.12",
31
26
  "yargs": "18.1.0"
32
27
  },
@@ -0,0 +1,10 @@
1
+ export type Config = Array<{
2
+ route: string;
3
+ source: string;
4
+ }>;
5
+ /**
6
+ * Builds a framework-agnostic request handler for the configured routes: the same `Request -> Response`
7
+ * shape the Next.js route handlers use, so it can sit behind Hono, `@hono/node-server`, or any other
8
+ * WHATWG-fetch-compatible server.
9
+ */
10
+ export declare function createResizerHandler(config: Config): (request: Request) => Promise<Response>;
package/src/handler.js ADDED
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createResizerHandler = createResizerHandler;
4
+ const fs_1 = require("fs");
5
+ const promises_1 = require("fs/promises");
6
+ const path_1 = require("path");
7
+ const get_source_filename_1 = require("./utils/get-source-filename");
8
+ const image_converter_1 = require("./utils/image-converter");
9
+ const log_1 = require("./utils/log");
10
+ const match_route_1 = require("./utils/match-route");
11
+ const tools_1 = require("./utils/tools");
12
+ function matchRoute(routes, pathname) {
13
+ for (const route of routes) {
14
+ const params = route.match(pathname);
15
+ if (params !== undefined) {
16
+ return { params, source: route.source };
17
+ }
18
+ }
19
+ return undefined;
20
+ }
21
+ async function fileResponse(filename, contentType) {
22
+ const [data, stats] = await Promise.all([(0, promises_1.readFile)(filename), (0, promises_1.stat)(filename)]);
23
+ return new Response(data, {
24
+ headers: {
25
+ "Content-Type": contentType,
26
+ "Content-Length": String(stats.size),
27
+ "Last-Modified": stats.mtime.toUTCString(),
28
+ },
29
+ });
30
+ }
31
+ async function fetchSource(url) {
32
+ const response = await fetch(url);
33
+ if (!response.ok) {
34
+ throw new Error(`Source ${url} responded with ${response.status}`);
35
+ }
36
+ return response.arrayBuffer();
37
+ }
38
+ /**
39
+ * Builds a framework-agnostic request handler for the configured routes: the same `Request -> Response`
40
+ * shape the Next.js route handlers use, so it can sit behind Hono, `@hono/node-server`, or any other
41
+ * WHATWG-fetch-compatible server.
42
+ */
43
+ function createResizerHandler(config) {
44
+ const routes = config.map((c, i) => {
45
+ (0, log_1.log)(`Config #${i + 1}:`);
46
+ (0, log_1.log)(` Route: ${c.route}`);
47
+ (0, log_1.log)(` Source: ${c.source}`);
48
+ return { match: (0, match_route_1.createRouteMatcher)(c.route), source: c.source };
49
+ });
50
+ return async (request) => {
51
+ var _a, _b;
52
+ try {
53
+ const { pathname } = new URL(request.url);
54
+ (0, log_1.log)("----------------------------------------");
55
+ (0, log_1.log)(`${request.method} ${pathname}`);
56
+ const matched = matchRoute(routes, pathname);
57
+ if (matched === undefined) {
58
+ (0, log_1.log)("No route matched", "warn");
59
+ return new Response("Not Found", { status: 404 });
60
+ }
61
+ const { params, source } = matched;
62
+ const generatedFilename = ((_a = process.env.UXF_RESIZER_GENERATE_PATH) !== null && _a !== void 0 ? _a : process.cwd()) + pathname;
63
+ (0, log_1.log)(`Generated filename: ${generatedFilename}`);
64
+ const contentType = params.toFormat === "svg" ? "image/svg+xml" : ((_b = tools_1.CONTENT_TYPES[params.toFormat]) !== null && _b !== void 0 ? _b : "image/jpeg");
65
+ if ((0, fs_1.existsSync)(generatedFilename)) {
66
+ return await fileResponse(generatedFilename, contentType);
67
+ }
68
+ await (0, promises_1.mkdir)((0, path_1.dirname)(generatedFilename), { recursive: true });
69
+ const sourceFilename = (0, get_source_filename_1.getSourceFilename)(source, params);
70
+ (0, log_1.log)(`Source filename: ${sourceFilename}`);
71
+ const isRemote = /^https?:\/\//.test(sourceFilename);
72
+ if (params.toFormat === "svg") {
73
+ if (isRemote) {
74
+ await (0, promises_1.writeFile)(generatedFilename, Buffer.from(await fetchSource(sourceFilename)));
75
+ }
76
+ else {
77
+ await (0, promises_1.copyFile)(sourceFilename, generatedFilename);
78
+ }
79
+ return await fileResponse(generatedFilename, contentType);
80
+ }
81
+ const sourceFile = isRemote ? await fetchSource(sourceFilename) : sourceFilename;
82
+ await (0, image_converter_1.convertImage)(sourceFile, generatedFilename, params);
83
+ return await fileResponse(generatedFilename, contentType);
84
+ }
85
+ catch (e) {
86
+ (0, log_1.log)(e, "error");
87
+ return new Response("Not Found", { status: 404 });
88
+ }
89
+ };
90
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * @jest-environment node
8
+ */
9
+ const fs_1 = require("fs");
10
+ const os_1 = require("os");
11
+ const path_1 = require("path");
12
+ const sharp_1 = __importDefault(require("sharp"));
13
+ const handler_1 = require("./handler");
14
+ const STATIC_ROUTE = "/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z0-9]+)_:trim([a-z0-9]+)_:quality(\\d+|x)/:version/:filename(.*).:extension.:toFormat";
15
+ let workDir;
16
+ let sourceDir;
17
+ let generatePath;
18
+ let handler;
19
+ let logSpy;
20
+ beforeAll(async () => {
21
+ workDir = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), "uxf-resizer-"));
22
+ sourceDir = (0, path_1.join)(workDir, "source");
23
+ generatePath = (0, path_1.join)(workDir, "generated");
24
+ process.env.UXF_RESIZER_GENERATE_PATH = generatePath;
25
+ (0, fs_1.mkdirSync)(sourceDir, { recursive: true });
26
+ await (0, sharp_1.default)({ create: { width: 40, height: 20, channels: 3, background: "#ff0000" } })
27
+ .png()
28
+ .toFile((0, path_1.join)(sourceDir, "img.png"));
29
+ const config = [{ route: STATIC_ROUTE, source: `${sourceDir}/:filename+.:extension` }];
30
+ logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
31
+ handler = (0, handler_1.createResizerHandler)(config);
32
+ });
33
+ afterAll(() => {
34
+ logSpy.mockRestore();
35
+ delete process.env.UXF_RESIZER_GENERATE_PATH;
36
+ (0, fs_1.rmSync)(workDir, { recursive: true, force: true });
37
+ });
38
+ test("generates, caches and serves a resized image", async () => {
39
+ const path = "/generated/static/20_x_cv_c_FFF_nt_x/1/img.png.webp";
40
+ const response = await handler(new Request(`http://localhost${path}`));
41
+ expect(response.status).toBe(200);
42
+ expect(response.headers.get("Content-Type")).toBe("image/webp");
43
+ expect(response.headers.get("Last-Modified")).toBeTruthy();
44
+ expect((0, fs_1.existsSync)(generatePath + path)).toBe(true);
45
+ const body = await response.arrayBuffer();
46
+ expect(response.headers.get("Content-Length")).toBe(String(body.byteLength));
47
+ const metadata = await (0, sharp_1.default)(Buffer.from(body)).metadata();
48
+ expect(metadata.format).toBe("webp");
49
+ expect(metadata.width).toBe(20);
50
+ // second request is served from the cache – same file, same headers
51
+ const cached = await handler(new Request(`http://localhost${path}?ignored=1`));
52
+ expect(cached.status).toBe(200);
53
+ expect(cached.headers.get("Content-Length")).toBe(response.headers.get("Content-Length"));
54
+ });
55
+ test("responds 404 for an unmatched path", async () => {
56
+ const response = await handler(new Request("http://localhost/generated/nonsense"));
57
+ expect(response.status).toBe(404);
58
+ });
59
+ test("responds 404 when the source file is missing", async () => {
60
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
61
+ const response = await handler(new Request("http://localhost/generated/static/20_x_cv_c_FFF_nt_x/1/missing.png.webp"));
62
+ expect(response.status).toBe(404);
63
+ expect(errorSpy).toHaveBeenCalled();
64
+ errorSpy.mockRestore();
65
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "fs";
5
+ import { tmpdir } from "os";
6
+ import { join } from "path";
7
+ import sharp from "sharp";
8
+ import { Config, createResizerHandler } from "./handler";
9
+
10
+ const STATIC_ROUTE =
11
+ "/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z0-9]+)_:trim([a-z0-9]+)_:quality(\\d+|x)/:version/:filename(.*).:extension.:toFormat";
12
+
13
+ let workDir: string;
14
+ let sourceDir: string;
15
+ let generatePath: string;
16
+ let handler: (request: Request) => Promise<Response>;
17
+ let logSpy: jest.SpyInstance;
18
+
19
+ beforeAll(async () => {
20
+ workDir = mkdtempSync(join(tmpdir(), "uxf-resizer-"));
21
+ sourceDir = join(workDir, "source");
22
+ generatePath = join(workDir, "generated");
23
+ process.env.UXF_RESIZER_GENERATE_PATH = generatePath;
24
+
25
+ mkdirSync(sourceDir, { recursive: true });
26
+ await sharp({ create: { width: 40, height: 20, channels: 3, background: "#ff0000" } })
27
+ .png()
28
+ .toFile(join(sourceDir, "img.png"));
29
+
30
+ const config: Config = [{ route: STATIC_ROUTE, source: `${sourceDir}/:filename+.:extension` }];
31
+ logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
32
+ handler = createResizerHandler(config);
33
+ });
34
+
35
+ afterAll(() => {
36
+ logSpy.mockRestore();
37
+ delete process.env.UXF_RESIZER_GENERATE_PATH;
38
+ rmSync(workDir, { recursive: true, force: true });
39
+ });
40
+
41
+ test("generates, caches and serves a resized image", async () => {
42
+ const path = "/generated/static/20_x_cv_c_FFF_nt_x/1/img.png.webp";
43
+
44
+ const response = await handler(new Request(`http://localhost${path}`));
45
+
46
+ expect(response.status).toBe(200);
47
+ expect(response.headers.get("Content-Type")).toBe("image/webp");
48
+ expect(response.headers.get("Last-Modified")).toBeTruthy();
49
+ expect(existsSync(generatePath + path)).toBe(true);
50
+
51
+ const body = await response.arrayBuffer();
52
+ expect(response.headers.get("Content-Length")).toBe(String(body.byteLength));
53
+
54
+ const metadata = await sharp(Buffer.from(body)).metadata();
55
+ expect(metadata.format).toBe("webp");
56
+ expect(metadata.width).toBe(20);
57
+
58
+ // second request is served from the cache – same file, same headers
59
+ const cached = await handler(new Request(`http://localhost${path}?ignored=1`));
60
+ expect(cached.status).toBe(200);
61
+ expect(cached.headers.get("Content-Length")).toBe(response.headers.get("Content-Length"));
62
+ });
63
+
64
+ test("responds 404 for an unmatched path", async () => {
65
+ const response = await handler(new Request("http://localhost/generated/nonsense"));
66
+
67
+ expect(response.status).toBe(404);
68
+ });
69
+
70
+ test("responds 404 when the source file is missing", async () => {
71
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
72
+ const response = await handler(
73
+ new Request("http://localhost/generated/static/20_x_cv_c_FFF_nt_x/1/missing.png.webp"),
74
+ );
75
+
76
+ expect(response.status).toBe(404);
77
+ expect(errorSpy).toHaveBeenCalled();
78
+ errorSpy.mockRestore();
79
+ });