@socialtip/asset-proxy-url-generator 0.5.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/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @socialtip/asset-proxy-url-generator
2
+
3
+ Generate [asset-proxy](../../README.md)-compatible URL paths programmatically, with optional source URL encryption and HMAC signing.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @socialtip/asset-proxy-url-generator
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { generateUrl } from "@socialtip/asset-proxy-url-generator";
15
+
16
+ const url = generateUrl({
17
+ sourceUrl: "https://example.com/photo.jpg",
18
+ outputFormat: "webp",
19
+ resize: { type: "fill", width: 480, height: 360 },
20
+ quality: 80,
21
+ });
22
+ // => /_/rs:fill:480:360/q:80/plain/https://example.com/photo.jpg@webp
23
+ ```
24
+
25
+ ### Encrypted source URLs
26
+
27
+ ```ts
28
+ const url = generateUrl(
29
+ { sourceUrl: "https://example.com/photo.jpg", outputFormat: "webp" },
30
+ { encryptionKey: "0123456789abcdef..." }, // hex-encoded 32-byte key
31
+ );
32
+ // => /_/enc/dGhpcyBpcy...@webp
33
+ ```
34
+
35
+ ### Signed URLs
36
+
37
+ ```ts
38
+ const url = generateUrl(
39
+ { sourceUrl: "https://example.com/photo.jpg" },
40
+ {
41
+ signingKey: "...", // hex-encoded HMAC key
42
+ signingSalt: "...", // hex-encoded salt
43
+ },
44
+ );
45
+ // => /oKfUtW34Dvo.../plain/https://example.com/photo.jpg
46
+ ```
47
+
48
+ ### All options
49
+
50
+ The `generateUrl` function accepts all processing options from the asset-proxy URL schema. See the [full options reference](../../README.md#processing-options) for details.
@@ -0,0 +1,25 @@
1
+ import { type ParsedUrlInput, type InfoOptions } from "@socialtip/asset-proxy-url-parser";
2
+ export type { ParsedUrlInput, InfoOptions, } from "@socialtip/asset-proxy-url-parser";
3
+ /** Options for generating a URL, derived from the asset-proxy parsed URL schema. All fields except `sourceUrl` are optional. */
4
+ export type UrlGeneratorOptions = Partial<ParsedUrlInput> & {
5
+ sourceUrl: string;
6
+ };
7
+ /** Configuration for URL encryption and signing. */
8
+ export interface UrlGeneratorConfig {
9
+ /** Hex-encoded 32-byte AES-256-CBC key for encrypting the source URL. When set, the source URL is encrypted and the `/enc/` prefix is used. */
10
+ encryptionKey?: string;
11
+ /** When true (and `encryptionKey` is set), derives the encryption IV from the source URL instead of using random bytes. This makes the generated URL deterministic for the same input, which is useful when URLs need to be stable for caching purposes. */
12
+ deterministicEncryption?: boolean;
13
+ /** Hex-encoded HMAC-SHA256 key for URL signing. Must be set together with `signingSalt`. */
14
+ signingKey?: string;
15
+ /** Hex-encoded salt prepended to the path before HMAC signing. Must be set together with `signingKey`. */
16
+ signingSalt?: string;
17
+ }
18
+ /** Generates an asset-proxy-compatible URL path. */
19
+ export declare function generateUrl(options: UrlGeneratorOptions, config?: UrlGeneratorConfig): string;
20
+ /** Options for generating an info URL. Only `sourceUrl` is required; security options (encryption, signing) come from the config. */
21
+ export interface InfoUrlOptions {
22
+ sourceUrl: string;
23
+ }
24
+ /** Generates an asset-proxy info URL path that returns JSON metadata about the source asset. */
25
+ export declare function generateInfoUrl(options: InfoUrlOptions, config?: UrlGeneratorConfig, infoOptions?: InfoOptions): string;
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ import { encryptSourceUrl, sign, SHORTHANDS, } from "@socialtip/asset-proxy-url-parser";
2
+ /** Generates an asset-proxy-compatible URL path. */
3
+ export function generateUrl(options, config) {
4
+ const segments = serializeOptions(options);
5
+ segments.sort((a, b) => {
6
+ const keyA = SHORTHANDS[a.slice(0, a.indexOf(":"))] ?? a;
7
+ const keyB = SHORTHANDS[b.slice(0, b.indexOf(":"))] ?? b;
8
+ return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
9
+ });
10
+ let sourceUrlPart;
11
+ if (config?.encryptionKey) {
12
+ const key = Buffer.from(config.encryptionKey, "hex");
13
+ sourceUrlPart = `enc/${encryptSourceUrl(options.sourceUrl, key, { deterministic: config.deterministicEncryption })}`;
14
+ }
15
+ else {
16
+ sourceUrlPart = `plain/${options.sourceUrl}`;
17
+ }
18
+ const optionsPath = segments.length > 0 ? segments.join("/") + "/" : "";
19
+ const pathAfterSignature = `/${optionsPath}${sourceUrlPart}`;
20
+ let signature = "insecure";
21
+ if (config?.signingKey && config?.signingSalt) {
22
+ signature = sign(pathAfterSignature, Buffer.from(config.signingKey, "hex"), Buffer.from(config.signingSalt, "hex"));
23
+ }
24
+ return `/${signature}${pathAfterSignature}`;
25
+ }
26
+ /** Generates an asset-proxy info URL path that returns JSON metadata about the source asset. */
27
+ export function generateInfoUrl(options, config, infoOptions) {
28
+ const infoSegments = serializeInfoOptions(infoOptions);
29
+ const infoPath = infoSegments.length > 0 ? infoSegments.join("/") + "/" : "";
30
+ let sourceUrlPart;
31
+ if (config?.encryptionKey) {
32
+ const key = Buffer.from(config.encryptionKey, "hex");
33
+ sourceUrlPart = `enc/${encryptSourceUrl(options.sourceUrl, key, { deterministic: config.deterministicEncryption })}`;
34
+ }
35
+ else {
36
+ sourceUrlPart = `plain/${options.sourceUrl}`;
37
+ }
38
+ const pathAfterSignature = `/${infoPath}${sourceUrlPart}`;
39
+ let signature = "insecure";
40
+ if (config?.signingKey && config?.signingSalt) {
41
+ signature = sign(pathAfterSignature, Buffer.from(config.signingKey, "hex"), Buffer.from(config.signingSalt, "hex"));
42
+ }
43
+ return `/info/${signature}${pathAfterSignature}`;
44
+ }
45
+ function serializeInfoOptions(options) {
46
+ if (!options)
47
+ return [];
48
+ const segments = [];
49
+ if (options.exif)
50
+ segments.push("exif:t");
51
+ if (options.iptc)
52
+ segments.push("iptc:t");
53
+ if (options.xmp)
54
+ segments.push("xmp:t");
55
+ if (options.colorspace)
56
+ segments.push("cs:t");
57
+ if (options.bands)
58
+ segments.push("b:t");
59
+ if (options.sampleFormat)
60
+ segments.push("sf:t");
61
+ if (options.pagesNumber)
62
+ segments.push("pn:t");
63
+ if (options.alpha)
64
+ segments.push("a:t");
65
+ if (options.palette)
66
+ segments.push(`p:${options.palette}`);
67
+ if (options.average) {
68
+ let seg = "avg:t";
69
+ if (options.average.ignoreTransparent)
70
+ seg += ":t";
71
+ segments.push(seg);
72
+ }
73
+ if (options.dominantColors)
74
+ segments.push("dc:t");
75
+ if (options.blurhash)
76
+ segments.push(`bh:${options.blurhash.xComponents}:${options.blurhash.yComponents}`);
77
+ if (options.calcHashsums?.length)
78
+ segments.push(`chs:${options.calcHashsums.join(":")}`);
79
+ if (options.page !== undefined)
80
+ segments.push(`pg:${options.page}`);
81
+ return segments;
82
+ }
83
+ function bool(v) {
84
+ return v ? "1" : "0";
85
+ }
86
+ function serializeGravity(g) {
87
+ if (typeof g === "string")
88
+ return g;
89
+ return `fp:${g.x}:${g.y}`;
90
+ }
91
+ function serializeOptions(options) {
92
+ const segments = [];
93
+ if (options.outputFormat)
94
+ segments.push(`f:${options.outputFormat}`);
95
+ if (options.resize) {
96
+ segments.push(`rs:${options.resize.type}:${options.resize.width}:${options.resize.height}`);
97
+ }
98
+ if (options.resizingAlgorithm) {
99
+ const ra = options.resizingAlgorithm;
100
+ if (ra.mode === "cpu") {
101
+ segments.push(`ra:${ra.algorithm}`);
102
+ }
103
+ else {
104
+ let s = `ra:gpu:${ra.scaler}`;
105
+ if (ra.algorithm)
106
+ s += `:${ra.algorithm}`;
107
+ segments.push(s);
108
+ }
109
+ }
110
+ if (options.minWidth !== undefined)
111
+ segments.push(`mw:${options.minWidth}`);
112
+ if (options.minHeight !== undefined)
113
+ segments.push(`mh:${options.minHeight}`);
114
+ if (options.enlarge !== undefined)
115
+ segments.push(`el:${bool(options.enlarge)}`);
116
+ if (options.extend) {
117
+ segments.push(`ex:${bool(options.extend.enabled)}:${options.extend.gravity}`);
118
+ }
119
+ if (options.extendAspectRatio) {
120
+ segments.push(`exar:${bool(options.extendAspectRatio.enabled)}:${options.extendAspectRatio.gravity}`);
121
+ }
122
+ if (options.crop) {
123
+ let s = `c:${options.crop.width}:${options.crop.height}`;
124
+ if (options.crop.gravity)
125
+ s += `:${serializeGravity(options.crop.gravity)}`;
126
+ segments.push(s);
127
+ }
128
+ if (options.cropAspectRatio !== undefined) {
129
+ segments.push(`car:${options.cropAspectRatio}:1`);
130
+ }
131
+ if (options.gravity) {
132
+ segments.push(`g:${serializeGravity(options.gravity)}`);
133
+ }
134
+ if (options.quality !== undefined)
135
+ segments.push(`q:${options.quality}`);
136
+ if (options.formatQuality) {
137
+ const parts = Object.entries(options.formatQuality).flatMap(([fmt, q]) => [
138
+ fmt,
139
+ String(q),
140
+ ]);
141
+ segments.push(`fq:${parts.join(":")}`);
142
+ }
143
+ if (options.autoquality) {
144
+ const aq = options.autoquality;
145
+ segments.push(`aq:${aq.method}:${aq.target}:${aq.min}:${aq.max}:${aq.allowedError}`);
146
+ }
147
+ if (options.maxBytes !== undefined)
148
+ segments.push(`mb:${options.maxBytes}`);
149
+ if (options.blur !== undefined)
150
+ segments.push(`bl:${options.blur}`);
151
+ if (options.sharpen !== undefined)
152
+ segments.push(`sh:${options.sharpen}`);
153
+ if (options.pixelate !== undefined)
154
+ segments.push(`px:${options.pixelate}`);
155
+ if (options.unsharpMasking) {
156
+ const u = options.unsharpMasking;
157
+ segments.push(`ush:${u.mode}:${u.weight}:${u.divider}`);
158
+ }
159
+ if (options.brightness !== undefined && options.brightness !== 0) {
160
+ segments.push(`br:${options.brightness}`);
161
+ }
162
+ if (options.contrast !== undefined && options.contrast !== 1) {
163
+ segments.push(`co:${options.contrast}`);
164
+ }
165
+ if (options.saturation !== undefined && options.saturation !== 1) {
166
+ segments.push(`sa:${options.saturation}`);
167
+ }
168
+ if (options.monochrome) {
169
+ segments.push(`mc:${options.monochrome.intensity}:${options.monochrome.colour}`);
170
+ }
171
+ if (options.duotone) {
172
+ segments.push(`dt:${options.duotone.intensity}:${options.duotone.colour1}:${options.duotone.colour2}`);
173
+ }
174
+ if (options.colorize) {
175
+ const c = options.colorize;
176
+ segments.push(`clrz:${c.opacity}:${c.colour}:${bool(c.keepAlpha)}`);
177
+ }
178
+ if (options.gradient) {
179
+ const g = options.gradient;
180
+ segments.push(`grd:${g.opacity}:${g.colour}:${g.direction}:${g.start}:${g.stop}`);
181
+ }
182
+ if (options.rotate !== undefined)
183
+ segments.push(`rot:${options.rotate}`);
184
+ if (options.flip) {
185
+ segments.push(`fl:${bool(options.flip.horizontal)}:${bool(options.flip.vertical)}`);
186
+ }
187
+ if (options.autoRotate !== undefined) {
188
+ segments.push(`ar:${bool(options.autoRotate)}`);
189
+ }
190
+ if (options.background) {
191
+ segments.push(`bg:${options.background.r}:${options.background.g}:${options.background.b}`);
192
+ }
193
+ if (options.backgroundAlpha !== undefined) {
194
+ segments.push(`bga:${options.backgroundAlpha}`);
195
+ }
196
+ if (options.padding) {
197
+ const p = options.padding;
198
+ segments.push(`pd:${p.top}:${p.right}:${p.bottom}:${p.left}`);
199
+ }
200
+ if (options.stripMetadata !== undefined) {
201
+ segments.push(`sm:${bool(options.stripMetadata)}`);
202
+ }
203
+ if (options.keepCopyright !== undefined) {
204
+ segments.push(`kcr:${bool(options.keepCopyright)}`);
205
+ }
206
+ if (options.stripColorProfile !== undefined) {
207
+ segments.push(`scp:${bool(options.stripColorProfile)}`);
208
+ }
209
+ if (options.dpi !== undefined)
210
+ segments.push(`dpi:${options.dpi}`);
211
+ if (options.enforceThumbnail !== undefined) {
212
+ segments.push(`eth:${bool(options.enforceThumbnail)}`);
213
+ }
214
+ if (options.framerate !== undefined)
215
+ segments.push(`fr:${options.framerate}`);
216
+ if (options.cut !== undefined)
217
+ segments.push(`ct:${options.cut}`);
218
+ if (options.mute !== undefined)
219
+ segments.push(`mu:${bool(options.mute)}`);
220
+ if (options.trim) {
221
+ const parts = [String(options.trim.threshold)];
222
+ if (options.trim.colour !== undefined ||
223
+ options.trim.equalHor ||
224
+ options.trim.equalVert) {
225
+ parts.push(options.trim.colour ?? "");
226
+ }
227
+ if (options.trim.equalHor || options.trim.equalVert) {
228
+ parts.push(bool(options.trim.equalHor), bool(options.trim.equalVert));
229
+ }
230
+ segments.push(`tr:${parts.join(":")}`);
231
+ }
232
+ if (options.videoThumbnailSecond !== undefined) {
233
+ segments.push(`vts:${options.videoThumbnailSecond}`);
234
+ }
235
+ if (options.videoThumbnailKeyframes !== undefined) {
236
+ segments.push(`vtk:${bool(options.videoThumbnailKeyframes)}`);
237
+ }
238
+ if (options.videoThumbnailAnimation) {
239
+ const v = options.videoThumbnailAnimation;
240
+ let vtaStr = `vta:${v.step}:${v.delay}:${v.frames}:${v.frameWidth}:${v.frameHeight}`;
241
+ if (v.fill ||
242
+ v.extendFrame ||
243
+ v.trim ||
244
+ (v.focusX !== undefined && v.focusX !== 0.5) ||
245
+ (v.focusY !== undefined && v.focusY !== 0.5)) {
246
+ vtaStr += `:${bool(v.extendFrame ?? false)}:${bool(v.trim ?? false)}:${bool(v.fill ?? false)}:${v.focusX ?? 0.5}:${v.focusY ?? 0.5}`;
247
+ }
248
+ segments.push(vtaStr);
249
+ }
250
+ if (options.jpegOptions) {
251
+ const j = options.jpegOptions;
252
+ let s = `jpgo:${bool(j.progressive)}:${bool(j.noSubsample)}:${bool(j.trellisQuant)}:${bool(j.overshootDeringing)}:${bool(j.optimizeScans)}`;
253
+ if (j.quantTable !== undefined)
254
+ s += `:${j.quantTable}`;
255
+ segments.push(s);
256
+ }
257
+ if (options.pngOptions) {
258
+ const p = options.pngOptions;
259
+ let s = `pngo:${bool(p.interlaced)}:${bool(p.quantize)}`;
260
+ if (p.quantizationColours !== undefined)
261
+ s += `:${p.quantizationColours}`;
262
+ segments.push(s);
263
+ }
264
+ if (options.webpOptions) {
265
+ const w = options.webpOptions;
266
+ let s = `wpo:${w.compression ?? ""}:${bool(w.smartSubsample)}`;
267
+ if (w.preset)
268
+ s += `:${w.preset}`;
269
+ segments.push(s);
270
+ }
271
+ if (options.avifOptions?.subsample) {
272
+ segments.push(`avo:${options.avifOptions.subsample}`);
273
+ }
274
+ if (options.skipProcessing?.length) {
275
+ segments.push(`skp:${options.skipProcessing.join(":")}`);
276
+ }
277
+ if (options.raw !== undefined)
278
+ segments.push(`raw:${bool(options.raw)}`);
279
+ if (options.cacheBuster !== undefined) {
280
+ segments.push(`cb:${options.cacheBuster}`);
281
+ }
282
+ if (options.expires !== undefined)
283
+ segments.push(`exp:${options.expires}`);
284
+ if (options.filename !== undefined)
285
+ segments.push(`fn:${options.filename}`);
286
+ if (options.returnAttachment !== undefined) {
287
+ segments.push(`att:${bool(options.returnAttachment)}`);
288
+ }
289
+ if (options.fallbackImageUrl !== undefined) {
290
+ segments.push(`fiu:${options.fallbackImageUrl}`);
291
+ }
292
+ if (options.hashsum) {
293
+ segments.push(`hs:${options.hashsum.type}:${options.hashsum.hash}`);
294
+ }
295
+ if (options.maxSrcResolution !== undefined) {
296
+ segments.push(`msr:${options.maxSrcResolution}`);
297
+ }
298
+ if (options.maxSrcFileSize !== undefined) {
299
+ segments.push(`msfs:${options.maxSrcFileSize}`);
300
+ }
301
+ if (options.maxAnimationFrames !== undefined) {
302
+ segments.push(`maf:${options.maxAnimationFrames}`);
303
+ }
304
+ if (options.maxAnimationFrameResolution !== undefined) {
305
+ segments.push(`mafr:${options.maxAnimationFrameResolution}`);
306
+ }
307
+ if (options.maxResultDimension !== undefined) {
308
+ segments.push(`mrd:${options.maxResultDimension}`);
309
+ }
310
+ if (options.bestFormat) {
311
+ segments.push("f:best");
312
+ }
313
+ return segments;
314
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@socialtip/asset-proxy-url-generator",
3
+ "version": "0.5.0",
4
+ "description": "Generate asset-proxy-compatible URL paths with optional encryption and signing",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.build.json",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest run"
21
+ },
22
+ "dependencies": {
23
+ "@socialtip/asset-proxy-url-parser": "workspace:*"
24
+ },
25
+ "devDependencies": {
26
+ "@imgproxy/imgproxy-node": "^1.1.0",
27
+ "@types/node": "^25.5.0",
28
+ "typescript": "^5.9.3",
29
+ "vitest": "^4.1.0"
30
+ },
31
+ "license": "UNLICENSED",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/SocialTip/asset-proxy.git",
35
+ "directory": "packages/url-generator"
36
+ }
37
+ }