@transloadit/utils 4.6.0 → 4.7.1
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 +54 -3
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/node.d.ts +8 -32
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +19 -98
- package/dist/smartCdn.d.ts +66 -5
- package/dist/smartCdn.d.ts.map +1 -1
- package/dist/smartCdn.js +212 -13
- package/dist/smartCdnImage.d.ts +68 -0
- package/dist/smartCdnImage.d.ts.map +1 -0
- package/dist/smartCdnImage.js +137 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,14 @@ Everything in the root export runs on WebCrypto, so it works in browsers (secure
|
|
|
14
14
|
`https://` or `localhost`), edge runtimes, and Node.
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getSignedSmartCdnUrl,
|
|
19
|
+
getSmartCdnUrl,
|
|
20
|
+
parseSmartCdnUrl,
|
|
21
|
+
signParams,
|
|
22
|
+
stripSmartCdnAuth,
|
|
23
|
+
verifyWebhookSignature,
|
|
24
|
+
} from '@transloadit/utils'
|
|
18
25
|
|
|
19
26
|
const signature = await signParams(paramsString, authSecret)
|
|
20
27
|
const verified = await verifyWebhookSignature({
|
|
@@ -31,6 +38,32 @@ const url = await getSignedSmartCdnUrl({
|
|
|
31
38
|
})
|
|
32
39
|
```
|
|
33
40
|
|
|
41
|
+
### Smart CDN URL grammar
|
|
42
|
+
|
|
43
|
+
The URL builders and parser share one grammar, so a URL built here parses back into the options
|
|
44
|
+
that built it (and vice versa):
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// Unsigned, for workspaces that do not require signature authentication.
|
|
48
|
+
const publicUrl = getSmartCdnUrl({ workspace, template, input, urlParams: { w: 640 } })
|
|
49
|
+
|
|
50
|
+
// Inverse of the builders: percent-decodes once, keeps repeated params as arrays,
|
|
51
|
+
// and returns `auth_key`/`exp`/`sig` separately as `auth`.
|
|
52
|
+
const { workspace, template, input, urlParams, auth } = parseSmartCdnUrl(url)
|
|
53
|
+
|
|
54
|
+
// Drops `auth_key`, `exp`, `sig` (and api2's `hsh`), leaving every other byte untouched.
|
|
55
|
+
const unsigned = stripSmartCdnAuth(url)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`auth_key`, `exp`, and `sig` are reserved: signed builders replace them and the unsigned builder
|
|
59
|
+
omits them. Other fields, including `hsh`, round-trip through the builders and parser.
|
|
60
|
+
|
|
61
|
+
Both builders accept a `baseUrl` that replaces `https://{workspace}.tlcdn.com`, for example a local
|
|
62
|
+
api2's URL Transform endpoint `https://api2-devdock.transloadit.dev/file/{workspace}` (a literal
|
|
63
|
+
`{workspace}` is substituted). The signature does not cover the host, so treat `baseUrl` as trusted
|
|
64
|
+
configuration and never derive it from user input. Pass the same `baseUrl` to `parseSmartCdnUrl` to
|
|
65
|
+
parse URLs built with it.
|
|
66
|
+
|
|
34
67
|
## Node usage
|
|
35
68
|
|
|
36
69
|
```ts
|
|
@@ -53,7 +86,12 @@ const imageCandidates = getSignedSmartCdnImageCandidates({
|
|
|
53
86
|
authSecret,
|
|
54
87
|
// Reuse one absolute expiry across a build instead of recomputing it per request.
|
|
55
88
|
expiresAt,
|
|
56
|
-
input
|
|
89
|
+
// The browser fallback is independent from the Template's input grammar.
|
|
90
|
+
fallbackUrl: '/images/photo.jpg',
|
|
91
|
+
// This workspace Template pins https://example.com/ and accepts a relative path.
|
|
92
|
+
input: 'images/photo.jpg',
|
|
93
|
+
sourceDimensions: { height: 1600, width: 2400 },
|
|
94
|
+
template: 'website-images',
|
|
57
95
|
widths: [320, 640, 960],
|
|
58
96
|
workspace,
|
|
59
97
|
})
|
|
@@ -70,7 +108,20 @@ for (const source of imageCandidates.sources) {
|
|
|
70
108
|
- `verifyWebhookSignature({ rawBody, signatureHeader, authSecret })`: validates webhook signatures.
|
|
71
109
|
- `getSignedSmartCdnUrl(options)`: async, WebCrypto-based Smart CDN URL signer. Byte-identical to
|
|
72
110
|
the Node variant below.
|
|
111
|
+
- `getSmartCdnUrl(options)`: unsigned Smart CDN URL builder (same options minus credentials/expiry).
|
|
112
|
+
- `parseSmartCdnUrl(url, { baseUrl?, workspace? })`: parses a Smart CDN URL into
|
|
113
|
+
`{ workspace, template, input, urlParams, auth?, baseUrl? }`; throws on anything else.
|
|
114
|
+
- `stripSmartCdnAuth(url)`: removes the signature parameters, byte-for-byte otherwise.
|
|
115
|
+
- `baseUrl` (option of both builders): trusted replacement for `https://{workspace}.tlcdn.com`.
|
|
73
116
|
- `signParamsSync(paramsString, authSecret, algorithm?)`: Node-only sync signature helper.
|
|
74
117
|
- `getSignedSmartCdnUrl(options)` from `@transloadit/utils/node`: synchronous Smart CDN URL signer.
|
|
75
118
|
- `getSignedSmartCdnImageCandidates(options)`: deterministic structured, signed AVIF and WebP
|
|
76
|
-
candidates plus
|
|
119
|
+
candidates plus an explicit browser fallback. `template` is mandatory: use a trusted workspace
|
|
120
|
+
Template that owns its source policy. `fallbackUrl` is deliberately separate from `input`, which
|
|
121
|
+
may use a Template-specific grammar such as a relative origin-pinned path. Supply
|
|
122
|
+
`sourceDimensions` to prevent upscaling and keep both output dimensions within backend limits.
|
|
123
|
+
- `createSmartCdnImageCandidates(options, sign)` from `@transloadit/utils`: the same deterministic
|
|
124
|
+
image policy with an injected synchronous signer, for framework and package adapters that own
|
|
125
|
+
their credential boundary.
|
|
126
|
+
- `resolveSmartCdnImageFormats(formats)` and `resolveSmartCdnImageWidths(widths, maximumWidth?)`:
|
|
127
|
+
shared validation and normalization for adapters that use a different image Built-in.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
2
2
|
export type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha384' | 'sha512';
|
|
3
|
-
export type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
3
|
+
export type { ParsedSmartCdnUrl, ParseSmartCdnUrlOptions, SmartCdnUnsignedUrlOptions, SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts';
|
|
4
|
+
export type { SignSmartCdnImageRequest, SmartCdnImageCandidate, SmartCdnImageCandidates, SmartCdnImageFormat, SmartCdnImageFormatQuality, SmartCdnImageFormats, SmartCdnImagePolicyOptions, SmartCdnImageSignRequest, SmartCdnImageSource, SmartCdnImageSourceDimensions, } from './smartCdnImage.ts';
|
|
4
5
|
export * from './assemblyInstructionsCompiler.ts';
|
|
6
|
+
export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts';
|
|
7
|
+
export { createSmartCdnImageCandidates, resolveSmartCdnImageFormats, resolveSmartCdnImageWidths, smartCdnImageMaxDimension, } from './smartCdnImage.ts';
|
|
5
8
|
export declare const signParams: (paramsString: string, authSecret: string, algorithm?: SignatureAlgorithm) => Promise<string>;
|
|
6
9
|
export type VerifyWebhookSignatureOptions = {
|
|
7
10
|
rawBody: string;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAIvD,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAExE,YAAY,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAIvD,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAExE,YAAY,EACV,iBAAiB,EACjB,uBAAuB,EACvB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,wBAAwB,EACxB,sBAAsB,EACtB,uBAAuB,EACvB,mBAAmB,EACnB,0BAA0B,EAC1B,oBAAoB,EACpB,0BAA0B,EAC1B,wBAAwB,EACxB,mBAAmB,EACnB,6BAA6B,GAC9B,MAAM,oBAAoB,CAAA;AAE3B,cAAc,mCAAmC,CAAA;AACjD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACnF,OAAO,EACL,6BAA6B,EAC7B,2BAA2B,EAC3B,0BAA0B,EAC1B,yBAAyB,GAC1B,MAAM,oBAAoB,CAAA;AAqD3B,eAAO,MAAM,UAAU,GACrB,cAAc,MAAM,EACpB,YAAY,MAAM,EAClB,YAAW,kBAA6B,KACvC,OAAO,CAAC,MAAM,CAOhB,CAAA;AAED,MAAM,MAAM,6BAA6B,GAAG;IAC1C,OAAO,EAAE,MAAM,CAAA;IACf,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,SAAS,6BAA6B,KACrC,OAAO,CAAC,OAAO,CAkBjB,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,oBAAoB,GAAU,MAAM,kBAAkB,KAAG,OAAO,CAAC,MAAM,CAInF,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { finishSmartCdnUrl, prepareSmartCdnUrl } from "./smartCdn.js";
|
|
2
2
|
export * from "./assemblyInstructionsCompiler.js";
|
|
3
|
+
export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from "./smartCdn.js";
|
|
4
|
+
export { createSmartCdnImageCandidates, resolveSmartCdnImageFormats, resolveSmartCdnImageWidths, smartCdnImageMaxDimension, } from "./smartCdnImage.js";
|
|
3
5
|
const algorithmMap = {
|
|
4
6
|
sha1: 'SHA-1',
|
|
5
7
|
sha256: 'SHA-256',
|
package/dist/node.d.ts
CHANGED
|
@@ -1,42 +1,18 @@
|
|
|
1
1
|
import type { SignatureAlgorithm } from './index.ts';
|
|
2
2
|
import type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
3
|
+
import type { SmartCdnImageCandidates, SmartCdnImagePolicyOptions } from './smartCdnImage.ts';
|
|
3
4
|
export type { SignatureAlgorithm } from './index.ts';
|
|
4
|
-
export type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
5
|
+
export type { ParsedSmartCdnUrl, ParseSmartCdnUrlOptions, SmartCdnUnsignedUrlOptions, SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts';
|
|
6
|
+
export type { SignSmartCdnImageRequest, SmartCdnImageCandidate, SmartCdnImageCandidates, SmartCdnImageFormat, SmartCdnImageFormatQuality, SmartCdnImageFormats, SmartCdnImagePolicyOptions, SmartCdnImageSignRequest, SmartCdnImageSource, SmartCdnImageSourceDimensions, } from './smartCdnImage.ts';
|
|
7
|
+
export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts';
|
|
8
|
+
export { resolveSmartCdnImageFormats, resolveSmartCdnImageWidths, smartCdnImageMaxDimension, } from './smartCdnImage.ts';
|
|
5
9
|
export type SignatureAlgorithmInput = SignatureAlgorithm | (string & {});
|
|
6
|
-
/** Image formats supported by the responsive-image Built-in. */
|
|
7
|
-
export type SmartCdnImageFormat = 'avif' | 'png' | 'webp';
|
|
8
|
-
/** One signed Smart CDN rendition at a specific intrinsic width. */
|
|
9
|
-
export interface SmartCdnImageCandidate {
|
|
10
|
-
url: string;
|
|
11
|
-
width: number;
|
|
12
|
-
}
|
|
13
|
-
/** Ordered candidates for one image format and quality. */
|
|
14
|
-
export interface SmartCdnImageSource {
|
|
15
|
-
candidates: readonly SmartCdnImageCandidate[];
|
|
16
|
-
format: SmartCdnImageFormat;
|
|
17
|
-
quality: number;
|
|
18
|
-
}
|
|
19
|
-
/** Structured data for rendering a responsive image. */
|
|
20
|
-
export interface SmartCdnImageCandidates {
|
|
21
|
-
fallbackUrl: string;
|
|
22
|
-
sources: readonly SmartCdnImageSource[];
|
|
23
|
-
}
|
|
24
10
|
/** Options for deterministic, server-generated Smart CDN image candidates. */
|
|
25
|
-
export interface SmartCdnImageCandidatesOptions {
|
|
11
|
+
export interface SmartCdnImageCandidatesOptions extends SmartCdnImagePolicyOptions {
|
|
26
12
|
/** Transloadit auth key used to sign every candidate URL. */
|
|
27
13
|
authKey: string;
|
|
28
14
|
/** Transloadit auth secret used to sign every candidate URL. */
|
|
29
15
|
authSecret: string;
|
|
30
|
-
/** One absolute expiry in milliseconds since UNIX epoch, shared by every candidate. */
|
|
31
|
-
expiresAt: number;
|
|
32
|
-
/** Formats and their quality values. Defaults to AVIF 45 and WebP 75. */
|
|
33
|
-
formats?: Readonly<Partial<Record<SmartCdnImageFormat, number>>>;
|
|
34
|
-
/** Absolute HTTP(S) source URL accepted by the responsive-image Template. */
|
|
35
|
-
input: string;
|
|
36
|
-
/** Compatible Template override. Defaults to `builtin/serve-image@0.0.1`. */
|
|
37
|
-
template?: string;
|
|
38
|
-
/** Up to 32 intrinsic widths. Each value must be an integer from 1 through 8000. */
|
|
39
|
-
widths: readonly number[];
|
|
40
16
|
/** Workspace slug. */
|
|
41
17
|
workspace: string;
|
|
42
18
|
}
|
|
@@ -46,8 +22,8 @@ export declare const getSignedSmartCdnUrl: (opts: SmartCdnUrlOptions) => string;
|
|
|
46
22
|
/**
|
|
47
23
|
* Builds deterministic signed Smart CDN candidates for server-rendered `<picture>` elements.
|
|
48
24
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
25
|
+
* Pass `sourceDimensions` when known so width descriptors remain truthful without producing a
|
|
26
|
+
* rendition above the backend's width or derived-height limits.
|
|
51
27
|
*/
|
|
52
28
|
export declare function getSignedSmartCdnImageCandidates(opts: SmartCdnImageCandidatesOptions): SmartCdnImageCandidates;
|
|
53
29
|
//# sourceMappingURL=node.d.ts.map
|
package/dist/node.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AACvD,OAAO,KAAK,EAAE,uBAAuB,EAAE,0BAA0B,EAAE,MAAM,oBAAoB,CAAA;AAO7F,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,YAAY,EACV,iBAAiB,EACjB,uBAAuB,EACvB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,wBAAwB,EACxB,sBAAsB,EACtB,uBAAuB,EACvB,mBAAmB,EACnB,0BAA0B,EAC1B,oBAAoB,EACpB,0BAA0B,EAC1B,wBAAwB,EACxB,mBAAmB,EACnB,6BAA6B,GAC9B,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACnF,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,EAC1B,yBAAyB,GAC1B,MAAM,oBAAoB,CAAA;AAE3B,MAAM,MAAM,uBAAuB,GAAG,kBAAkB,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;AAExE,8EAA8E;AAC9E,MAAM,WAAW,8BAA+B,SAAQ,0BAA0B;IAChF,6DAA6D;IAC7D,OAAO,EAAE,MAAM,CAAA;IACf,gEAAgE;IAChE,UAAU,EAAE,MAAM,CAAA;IAClB,sBAAsB;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,eAAO,MAAM,cAAc,GACzB,cAAc,MAAM,EACpB,YAAY,MAAM,EAClB,YAAW,uBAAkC,KAC5C,MAKF,CAAA;AAED,4FAA4F;AAC5F,eAAO,MAAM,oBAAoB,GAAI,MAAM,kBAAkB,KAAG,MAM/D,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,8BAA8B,GACnC,uBAAuB,CAsBzB"}
|
package/dist/node.js
CHANGED
|
@@ -1,57 +1,8 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import { finishSmartCdnUrl, prepareSmartCdnUrl } from "./smartCdn.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
};
|
|
7
|
-
const defaultSmartCdnImageTemplate = 'builtin/serve-image@0.0.1';
|
|
8
|
-
const smartCdnImageFormats = ['avif', 'webp', 'png'];
|
|
9
|
-
const smartCdnImageMaxDimension = 8000;
|
|
10
|
-
const smartCdnImageMaxWidths = 32;
|
|
11
|
-
function isSmartCdnImageFormat(value) {
|
|
12
|
-
return value === 'avif' || value === 'png' || value === 'webp';
|
|
13
|
-
}
|
|
14
|
-
function validateSmartCdnImageDimension(width) {
|
|
15
|
-
if (!Number.isInteger(width) || width < 1 || width > smartCdnImageMaxDimension) {
|
|
16
|
-
throw new RangeError(`width must be an integer from 1 through ${smartCdnImageMaxDimension}`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
function validateSmartCdnImageQuality(quality) {
|
|
20
|
-
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
|
|
21
|
-
throw new RangeError('quality must be an integer from 1 through 100');
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
function validateSmartCdnImageInput(input) {
|
|
25
|
-
if (typeof input !== 'string' || input.trim() !== input || input.includes('|')) {
|
|
26
|
-
throw new TypeError('input must be a single HTTP or HTTPS URL string');
|
|
27
|
-
}
|
|
28
|
-
if (!URL.canParse(input)) {
|
|
29
|
-
throw new TypeError('input must be an HTTP or HTTPS URL');
|
|
30
|
-
}
|
|
31
|
-
const protocol = new URL(input).protocol;
|
|
32
|
-
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
33
|
-
throw new TypeError('input must be an HTTP or HTTPS URL');
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
function validateSmartCdnImageFormats(formats) {
|
|
37
|
-
for (const format of Object.keys(formats)) {
|
|
38
|
-
if (!isSmartCdnImageFormat(format)) {
|
|
39
|
-
throw new TypeError(`Unsupported Smart CDN image format: ${format}`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
let formatCount = 0;
|
|
43
|
-
for (const format of smartCdnImageFormats) {
|
|
44
|
-
const quality = formats[format];
|
|
45
|
-
if (quality == null) {
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
validateSmartCdnImageQuality(quality);
|
|
49
|
-
formatCount += 1;
|
|
50
|
-
}
|
|
51
|
-
if (formatCount === 0) {
|
|
52
|
-
throw new TypeError('formats must contain at least one value');
|
|
53
|
-
}
|
|
54
|
-
}
|
|
3
|
+
import { createSmartCdnImageCandidates } from "./smartCdnImage.js";
|
|
4
|
+
export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from "./smartCdn.js";
|
|
5
|
+
export { resolveSmartCdnImageFormats, resolveSmartCdnImageWidths, smartCdnImageMaxDimension, } from "./smartCdnImage.js";
|
|
55
6
|
export const signParamsSync = (paramsString, authSecret, algorithm = 'sha384') => {
|
|
56
7
|
const signature = createHmac(algorithm, authSecret)
|
|
57
8
|
.update(Buffer.from(paramsString, 'utf-8'))
|
|
@@ -69,56 +20,26 @@ export const getSignedSmartCdnUrl = (opts) => {
|
|
|
69
20
|
/**
|
|
70
21
|
* Builds deterministic signed Smart CDN candidates for server-rendered `<picture>` elements.
|
|
71
22
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
23
|
+
* Pass `sourceDimensions` when known so width descriptors remain truthful without producing a
|
|
24
|
+
* rendition above the backend's width or derived-height limits.
|
|
74
25
|
*/
|
|
75
26
|
export function getSignedSmartCdnImageCandidates(opts) {
|
|
76
|
-
|
|
27
|
+
const authKey = opts.authKey;
|
|
28
|
+
const authSecret = opts.authSecret;
|
|
29
|
+
const workspace = opts.workspace;
|
|
30
|
+
if (typeof authKey !== 'string' || authKey === '') {
|
|
77
31
|
throw new TypeError('authKey is required');
|
|
78
32
|
}
|
|
79
|
-
if (typeof
|
|
33
|
+
if (typeof authSecret !== 'string' || authSecret === '') {
|
|
80
34
|
throw new TypeError('authSecret is required');
|
|
81
35
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
validateSmartCdnImageInput(opts.input);
|
|
92
|
-
const widths = [...new Set(opts.widths)];
|
|
93
|
-
if (widths.length > smartCdnImageMaxWidths) {
|
|
94
|
-
throw new RangeError(`widths must contain at most ${smartCdnImageMaxWidths} unique values`);
|
|
95
|
-
}
|
|
96
|
-
for (const width of widths) {
|
|
97
|
-
validateSmartCdnImageDimension(width);
|
|
98
|
-
}
|
|
99
|
-
const formats = opts.formats ?? defaultSmartCdnImageFormats;
|
|
100
|
-
validateSmartCdnImageFormats(formats);
|
|
101
|
-
widths.sort((left, right) => left - right);
|
|
102
|
-
const sources = [];
|
|
103
|
-
for (const format of smartCdnImageFormats) {
|
|
104
|
-
const quality = formats[format];
|
|
105
|
-
if (quality == null) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
const candidates = [];
|
|
109
|
-
for (const width of widths) {
|
|
110
|
-
const url = getSignedSmartCdnUrl({
|
|
111
|
-
authKey: opts.authKey,
|
|
112
|
-
authSecret: opts.authSecret,
|
|
113
|
-
expiresAt: opts.expiresAt,
|
|
114
|
-
input: opts.input,
|
|
115
|
-
template: opts.template ?? defaultSmartCdnImageTemplate,
|
|
116
|
-
urlParams: { f: format, q: quality, r: 'fit', w: width },
|
|
117
|
-
workspace: opts.workspace,
|
|
118
|
-
});
|
|
119
|
-
candidates.push({ url, width });
|
|
120
|
-
}
|
|
121
|
-
sources.push({ candidates, format, quality });
|
|
122
|
-
}
|
|
123
|
-
return { fallbackUrl: opts.input, sources };
|
|
36
|
+
return createSmartCdnImageCandidates(opts, (request) => getSignedSmartCdnUrl({
|
|
37
|
+
authKey,
|
|
38
|
+
authSecret,
|
|
39
|
+
expiresAt: request.expiresAt,
|
|
40
|
+
input: request.input,
|
|
41
|
+
template: request.template,
|
|
42
|
+
urlParams: { ...request.urlParams },
|
|
43
|
+
workspace,
|
|
44
|
+
}));
|
|
124
45
|
}
|
package/dist/smartCdn.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Smart CDN URL
|
|
3
|
-
* asynchronous WebCrypto signer (`@transloadit/utils`)
|
|
4
|
-
*
|
|
2
|
+
* Smart CDN URL grammar shared by the synchronous Node signer (`@transloadit/utils/node`) and the
|
|
3
|
+
* asynchronous WebCrypto signer (`@transloadit/utils`): building (signed and unsigned), parsing,
|
|
4
|
+
* and stripping signature parameters. Only the HMAC differs between the two signers, so the
|
|
5
|
+
* string-to-sign and the final URL are assembled here and cannot drift apart.
|
|
5
6
|
*/
|
|
7
|
+
export type SmartCdnUrlParams = Record<string, boolean | number | string | (boolean | number | string)[]>;
|
|
6
8
|
export type SmartCdnUrlOptions = {
|
|
7
9
|
/**
|
|
8
10
|
* Workspace slug.
|
|
@@ -17,9 +19,10 @@ export type SmartCdnUrlOptions = {
|
|
|
17
19
|
*/
|
|
18
20
|
input: string;
|
|
19
21
|
/**
|
|
20
|
-
* Additional parameters for the URL query string.
|
|
22
|
+
* Additional parameters for the URL query string. `auth_key`, `exp`, and `sig` are reserved:
|
|
23
|
+
* signed builders replace them and unsigned builders omit them.
|
|
21
24
|
*/
|
|
22
|
-
urlParams?:
|
|
25
|
+
urlParams?: SmartCdnUrlParams;
|
|
23
26
|
/**
|
|
24
27
|
* Expiration timestamp of the signature in milliseconds since UNIX epoch.
|
|
25
28
|
* Defaults to 1 hour from now.
|
|
@@ -33,7 +36,19 @@ export type SmartCdnUrlOptions = {
|
|
|
33
36
|
* Transloadit auth secret used to sign the URL.
|
|
34
37
|
*/
|
|
35
38
|
authSecret: string;
|
|
39
|
+
/**
|
|
40
|
+
* Base URL that replaces `https://{workspace}.tlcdn.com`, e.g. a local api2's URL Transform
|
|
41
|
+
* endpoint `https://api2-devdock.transloadit.dev/file/{workspace}`. A literal `{workspace}` is
|
|
42
|
+
* substituted with the encoded workspace slug; a trailing slash is ignored.
|
|
43
|
+
*
|
|
44
|
+
* **Trusted configuration only.** The signature does not cover the host, so a base URL taken from
|
|
45
|
+
* user input would let anyone redirect a signed URL (auth key included) to an origin of their
|
|
46
|
+
* choosing. Never derive it from request data.
|
|
47
|
+
*/
|
|
48
|
+
baseUrl?: string;
|
|
36
49
|
};
|
|
50
|
+
/** Options for an unsigned Smart CDN URL: the signed options without credentials or expiry. */
|
|
51
|
+
export type SmartCdnUnsignedUrlOptions = Omit<SmartCdnUrlOptions, 'authKey' | 'authSecret' | 'expiresAt'>;
|
|
37
52
|
/** A Smart CDN URL with everything but its signature in place. */
|
|
38
53
|
export interface PreparedSmartCdnUrl {
|
|
39
54
|
/** `workspace/template/input?sortedQuery`, the message the auth secret signs with HMAC-SHA256. */
|
|
@@ -44,10 +59,56 @@ export interface PreparedSmartCdnUrl {
|
|
|
44
59
|
templateSlug: string;
|
|
45
60
|
inputField: string;
|
|
46
61
|
queryParams: URLSearchParams;
|
|
62
|
+
/** Resolved origin + path prefix that precedes `/{template}/{input}`. */
|
|
63
|
+
baseUrl: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** The components of a Smart CDN URL, as produced by `parseSmartCdnUrl`. */
|
|
67
|
+
export interface ParsedSmartCdnUrl {
|
|
68
|
+
workspace: string;
|
|
69
|
+
template: string;
|
|
70
|
+
input: string;
|
|
71
|
+
/** Every query parameter except auth fields; repeated parameters become arrays. */
|
|
72
|
+
urlParams: Record<string, string | string[]>;
|
|
73
|
+
/** Present when the URL carries `auth_key`, `exp` and `sig`. */
|
|
74
|
+
auth?: {
|
|
75
|
+
key: string;
|
|
76
|
+
/** Milliseconds since UNIX epoch. */
|
|
77
|
+
expiresAt: number;
|
|
78
|
+
/** The `sig` value, e.g. `sha256:…`. */
|
|
79
|
+
signature: string;
|
|
47
80
|
};
|
|
81
|
+
/** Only set when the URL was parsed against a custom `baseUrl`; feeds straight back into the builders. */
|
|
82
|
+
baseUrl?: string;
|
|
83
|
+
}
|
|
84
|
+
export interface ParseSmartCdnUrlOptions {
|
|
85
|
+
/**
|
|
86
|
+
* The same trusted `baseUrl` the URL was built with (with or without `{workspace}`). Without it
|
|
87
|
+
* only `https://{workspace}.tlcdn.com/…` URLs are accepted.
|
|
88
|
+
*/
|
|
89
|
+
baseUrl?: string;
|
|
90
|
+
/** Workspace slug for a `baseUrl` without a `{workspace}` placeholder, where the URL cannot tell. */
|
|
91
|
+
workspace?: string;
|
|
48
92
|
}
|
|
49
93
|
/** Validates the options and assembles the string to sign; the caller supplies the HMAC. */
|
|
50
94
|
export declare const prepareSmartCdnUrl: (opts: SmartCdnUrlOptions) => PreparedSmartCdnUrl;
|
|
51
95
|
/** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */
|
|
52
96
|
export declare const finishSmartCdnUrl: ({ parts }: PreparedSmartCdnUrl, signatureHex: string) => string;
|
|
97
|
+
/**
|
|
98
|
+
* Builds an unsigned Smart CDN URL (`https://{workspace}.tlcdn.com/{template}/{input}?sortedQuery`)
|
|
99
|
+
* for workspaces that do not require signature authentication.
|
|
100
|
+
*/
|
|
101
|
+
export declare const getSmartCdnUrl: (opts: SmartCdnUnsignedUrlOptions) => string;
|
|
102
|
+
/**
|
|
103
|
+
* Removes the signature parameters (`auth_key`, `exp`, `sig`, and api2's `hsh`) from a Smart CDN
|
|
104
|
+
* URL. Every other byte of the URL is left untouched, so the result stays comparable with URLs
|
|
105
|
+
* produced elsewhere. Idempotent.
|
|
106
|
+
*/
|
|
107
|
+
export declare const stripSmartCdnAuth: (url: string) => string;
|
|
108
|
+
/**
|
|
109
|
+
* Parses a Smart CDN URL back into the options that built it: the inverse of `getSmartCdnUrl` and
|
|
110
|
+
* `getSignedSmartCdnUrl`. Path segments are percent-decoded exactly once; query parameters are
|
|
111
|
+
* decoded by `URLSearchParams` semantics; `auth_key`/`exp`/`sig` are returned separately as `auth`.
|
|
112
|
+
*/
|
|
113
|
+
export declare const parseSmartCdnUrl: (url: string, options?: ParseSmartCdnUrlOptions) => ParsedSmartCdnUrl;
|
|
53
114
|
//# sourceMappingURL=smartCdn.d.ts.map
|
package/dist/smartCdn.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"smartCdn.d.ts","sourceRoot":"","sources":["../src/smartCdn.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"smartCdn.d.ts","sourceRoot":"","sources":["../src/smartCdn.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAQH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CACpC,MAAM,EACN,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,CAAC,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,EAAE,CAC1D,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;OAEG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAA;IAC7B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;OAEG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;OAEG;IACH,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,+FAA+F;AAC/F,MAAM,MAAM,0BAA0B,GAAG,IAAI,CAC3C,kBAAkB,EAClB,SAAS,GAAG,YAAY,GAAG,WAAW,CACvC,CAAA;AAED,kEAAkE;AAClE,MAAM,WAAW,mBAAmB;IAClC,kGAAkG;IAClG,YAAY,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,KAAK,EAAE;QACL,aAAa,EAAE,MAAM,CAAA;QACrB,YAAY,EAAE,MAAM,CAAA;QACpB,UAAU,EAAE,MAAM,CAAA;QAClB,WAAW,EAAE,eAAe,CAAA;QAC5B,yEAAyE;QACzE,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;CACF;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAA;IAC5C,gEAAgE;IAChE,IAAI,CAAC,EAAE;QACL,GAAG,EAAE,MAAM,CAAA;QACX,qCAAqC;QACrC,SAAS,EAAE,MAAM,CAAA;QACjB,wCAAwC;QACxC,SAAS,EAAE,MAAM,CAAA;KAClB,CAAA;IACD,0GAA0G;IAC1G,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qGAAqG;IACrG,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAyCD,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,GAAI,MAAM,kBAAkB,KAAG,mBAyB7D,CAAA;AAED,+FAA+F;AAC/F,eAAO,MAAM,iBAAiB,GAAI,WAAW,mBAAmB,EAAE,cAAc,MAAM,KAAG,MAIxF,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,0BAA0B,KAAG,MAajE,CAAA;AAUD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,MAsB/C,CAAA;AAwDD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAC3B,KAAK,MAAM,EACX,UAAS,uBAA4B,KACpC,iBA4DF,CAAA"}
|
package/dist/smartCdn.js
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Smart CDN URL
|
|
3
|
-
* asynchronous WebCrypto signer (`@transloadit/utils`)
|
|
4
|
-
*
|
|
2
|
+
* Smart CDN URL grammar shared by the synchronous Node signer (`@transloadit/utils/node`) and the
|
|
3
|
+
* asynchronous WebCrypto signer (`@transloadit/utils`): building (signed and unsigned), parsing,
|
|
4
|
+
* and stripping signature parameters. Only the HMAC differs between the two signers, so the
|
|
5
|
+
* string-to-sign and the final URL are assembled here and cannot drift apart.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const SMART_CDN_HOST_SUFFIX = '.tlcdn.com';
|
|
8
|
+
const WORKSPACE_PLACEHOLDER = '{workspace}';
|
|
9
|
+
/** Query parameters that carry the signature; `hsh` is an api2-side hash that is stripped too. */
|
|
10
|
+
const SIGNATURE_PARAMS = new Set(['auth_key', 'exp', 'sig']);
|
|
11
|
+
const STRIPPED_PARAMS = new Set([...SIGNATURE_PARAMS, 'hsh']);
|
|
12
|
+
const validateRequired = (opts) => {
|
|
8
13
|
if (opts.workspace == null || opts.workspace === '')
|
|
9
14
|
throw new TypeError('workspace is required');
|
|
10
15
|
if (opts.template == null || opts.template === '')
|
|
11
16
|
throw new TypeError('template is required');
|
|
12
17
|
if (opts.input == null)
|
|
13
18
|
throw new TypeError('input is required');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
};
|
|
20
|
+
const resolveBaseUrl = (baseUrl, workspaceSlug) => {
|
|
21
|
+
if (baseUrl == null)
|
|
22
|
+
return `https://${workspaceSlug}${SMART_CDN_HOST_SUFFIX}`;
|
|
23
|
+
const resolved = baseUrl.replace(/\/+$/, '').split(WORKSPACE_PLACEHOLDER).join(workspaceSlug);
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(resolved);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new TypeError(`baseUrl must be an absolute URL, got '${baseUrl}'`);
|
|
30
|
+
}
|
|
31
|
+
if (parsed.search !== '' || parsed.hash !== '') {
|
|
32
|
+
throw new TypeError('baseUrl must not contain a query string or fragment');
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
};
|
|
36
|
+
const buildQueryParams = (urlParams) => {
|
|
18
37
|
const queryParams = new URLSearchParams();
|
|
19
|
-
for (const [key, value] of Object.entries(
|
|
38
|
+
for (const [key, value] of Object.entries(urlParams || {})) {
|
|
20
39
|
if (Array.isArray(value)) {
|
|
21
40
|
for (const val of value) {
|
|
22
41
|
queryParams.append(key, `${val}`);
|
|
@@ -26,17 +45,197 @@ export const prepareSmartCdnUrl = (opts) => {
|
|
|
26
45
|
queryParams.append(key, `${value}`);
|
|
27
46
|
}
|
|
28
47
|
}
|
|
48
|
+
return queryParams;
|
|
49
|
+
};
|
|
50
|
+
/** Validates the options and assembles the string to sign; the caller supplies the HMAC. */
|
|
51
|
+
export const prepareSmartCdnUrl = (opts) => {
|
|
52
|
+
validateRequired(opts);
|
|
53
|
+
const workspaceSlug = encodeURIComponent(opts.workspace);
|
|
54
|
+
const templateSlug = encodeURIComponent(opts.template);
|
|
55
|
+
const inputField = encodeURIComponent(opts.input);
|
|
56
|
+
const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000;
|
|
57
|
+
const queryParams = buildQueryParams(opts.urlParams);
|
|
58
|
+
// Keep accepting legacy values: the signer safely replaces its own authentication fields.
|
|
59
|
+
queryParams.delete('sig');
|
|
29
60
|
queryParams.set('auth_key', opts.authKey);
|
|
30
61
|
queryParams.set('exp', `${expiresAt}`);
|
|
31
62
|
queryParams.sort();
|
|
32
63
|
return {
|
|
33
64
|
stringToSign: `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}`,
|
|
34
|
-
parts: {
|
|
65
|
+
parts: {
|
|
66
|
+
workspaceSlug,
|
|
67
|
+
templateSlug,
|
|
68
|
+
inputField,
|
|
69
|
+
queryParams,
|
|
70
|
+
baseUrl: resolveBaseUrl(opts.baseUrl, workspaceSlug),
|
|
71
|
+
},
|
|
35
72
|
};
|
|
36
73
|
};
|
|
37
74
|
/** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */
|
|
38
75
|
export const finishSmartCdnUrl = ({ parts }, signatureHex) => {
|
|
39
|
-
const {
|
|
76
|
+
const { baseUrl, templateSlug, inputField, queryParams } = parts;
|
|
40
77
|
queryParams.set('sig', `sha256:${signatureHex}`);
|
|
41
|
-
return
|
|
78
|
+
return `${baseUrl}/${templateSlug}/${inputField}?${queryParams}`;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Builds an unsigned Smart CDN URL (`https://{workspace}.tlcdn.com/{template}/{input}?sortedQuery`)
|
|
82
|
+
* for workspaces that do not require signature authentication.
|
|
83
|
+
*/
|
|
84
|
+
export const getSmartCdnUrl = (opts) => {
|
|
85
|
+
validateRequired(opts);
|
|
86
|
+
const workspaceSlug = encodeURIComponent(opts.workspace);
|
|
87
|
+
const templateSlug = encodeURIComponent(opts.template);
|
|
88
|
+
const inputField = encodeURIComponent(opts.input);
|
|
89
|
+
const queryParams = buildQueryParams(opts.urlParams);
|
|
90
|
+
// An unsigned builder must not emit fields that make the URL look partially or fully signed.
|
|
91
|
+
for (const param of SIGNATURE_PARAMS)
|
|
92
|
+
queryParams.delete(param);
|
|
93
|
+
queryParams.sort();
|
|
94
|
+
const query = queryParams.toString();
|
|
95
|
+
return `${resolveBaseUrl(opts.baseUrl, workspaceSlug)}/${templateSlug}/${inputField}${query === '' ? '' : `?${query}`}`;
|
|
96
|
+
};
|
|
97
|
+
const decodeOnce = (value, what) => {
|
|
98
|
+
try {
|
|
99
|
+
return decodeURIComponent(value);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new TypeError(`Not a Smart CDN URL: malformed percent-encoding in ${what}`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Removes the signature parameters (`auth_key`, `exp`, `sig`, and api2's `hsh`) from a Smart CDN
|
|
107
|
+
* URL. Every other byte of the URL is left untouched, so the result stays comparable with URLs
|
|
108
|
+
* produced elsewhere. Idempotent.
|
|
109
|
+
*/
|
|
110
|
+
export const stripSmartCdnAuth = (url) => {
|
|
111
|
+
const hashIndex = url.indexOf('#');
|
|
112
|
+
const fragment = hashIndex === -1 ? '' : url.slice(hashIndex);
|
|
113
|
+
const withoutFragment = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
114
|
+
const queryIndex = withoutFragment.indexOf('?');
|
|
115
|
+
if (queryIndex === -1)
|
|
116
|
+
return url;
|
|
117
|
+
const path = withoutFragment.slice(0, queryIndex);
|
|
118
|
+
const kept = withoutFragment
|
|
119
|
+
.slice(queryIndex + 1)
|
|
120
|
+
.split('&')
|
|
121
|
+
.filter((pair) => {
|
|
122
|
+
if (pair === '')
|
|
123
|
+
return false;
|
|
124
|
+
const rawName = pair.slice(0, pair.indexOf('=') === -1 ? pair.length : pair.indexOf('='));
|
|
125
|
+
let name = rawName;
|
|
126
|
+
try {
|
|
127
|
+
name = decodeURIComponent(rawName.replace(/\+/g, ' '));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// An undecodable name is never one of ours; keep it.
|
|
131
|
+
}
|
|
132
|
+
return !STRIPPED_PARAMS.has(name);
|
|
133
|
+
});
|
|
134
|
+
return `${path}${kept.length === 0 ? '' : `?${kept.join('&')}`}${fragment}`;
|
|
135
|
+
};
|
|
136
|
+
const notSmartCdnUrl = (detail) => new TypeError(`Not a Smart CDN URL: ${detail} (expected https://{workspace}.tlcdn.com/{template}/{input}, or the configured baseUrl)`);
|
|
137
|
+
/** Splits `origin + pathname` into the workspace slug and the `{template}/{input}` remainder. */
|
|
138
|
+
const locateSmartCdnPath = (parsed, options) => {
|
|
139
|
+
const full = `${parsed.origin}${parsed.pathname}`;
|
|
140
|
+
if (options.baseUrl == null) {
|
|
141
|
+
const match = /^([^.]+)\.tlcdn\.com$/i.exec(parsed.hostname);
|
|
142
|
+
if (match?.[1] == null || parsed.protocol !== 'https:') {
|
|
143
|
+
throw notSmartCdnUrl(`unexpected origin '${parsed.origin}'`);
|
|
144
|
+
}
|
|
145
|
+
return { workspaceSlug: match[1], remainder: parsed.pathname.slice(1) };
|
|
146
|
+
}
|
|
147
|
+
const template = options.baseUrl.replace(/\/+$/, '');
|
|
148
|
+
const placeholderIndex = template.indexOf(WORKSPACE_PLACEHOLDER);
|
|
149
|
+
if (placeholderIndex === -1) {
|
|
150
|
+
const prefix = `${template}/`;
|
|
151
|
+
if (!full.startsWith(prefix))
|
|
152
|
+
throw notSmartCdnUrl(`'${full}' is not under baseUrl '${template}'`);
|
|
153
|
+
const hostMatch = /^([^.]+)\.tlcdn\.com$/i.exec(parsed.hostname);
|
|
154
|
+
const workspaceSlug = options.workspace != null ? encodeURIComponent(options.workspace) : hostMatch?.[1];
|
|
155
|
+
if (workspaceSlug == null) {
|
|
156
|
+
throw notSmartCdnUrl('the workspace cannot be determined; pass `workspace` next to a baseUrl without {workspace}');
|
|
157
|
+
}
|
|
158
|
+
return { workspaceSlug, remainder: full.slice(prefix.length), baseUrl: template };
|
|
159
|
+
}
|
|
160
|
+
const before = template.slice(0, placeholderIndex);
|
|
161
|
+
const after = template.slice(placeholderIndex + WORKSPACE_PLACEHOLDER.length);
|
|
162
|
+
if (!full.startsWith(before))
|
|
163
|
+
throw notSmartCdnUrl(`'${full}' is not under baseUrl '${template}'`);
|
|
164
|
+
const rest = full.slice(before.length);
|
|
165
|
+
const slashIndex = rest.indexOf('/');
|
|
166
|
+
const workspaceSlug = slashIndex === -1 ? rest : rest.slice(0, slashIndex);
|
|
167
|
+
const afterPart = slashIndex === -1 ? '' : rest.slice(slashIndex);
|
|
168
|
+
if (workspaceSlug === '' || !afterPart.startsWith(`${after}/`)) {
|
|
169
|
+
throw notSmartCdnUrl(`'${full}' does not match baseUrl '${template}'`);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
workspaceSlug,
|
|
173
|
+
remainder: afterPart.slice(after.length + 1),
|
|
174
|
+
baseUrl: `${before}${workspaceSlug}${after}`,
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Parses a Smart CDN URL back into the options that built it: the inverse of `getSmartCdnUrl` and
|
|
179
|
+
* `getSignedSmartCdnUrl`. Path segments are percent-decoded exactly once; query parameters are
|
|
180
|
+
* decoded by `URLSearchParams` semantics; `auth_key`/`exp`/`sig` are returned separately as `auth`.
|
|
181
|
+
*/
|
|
182
|
+
export const parseSmartCdnUrl = (url, options = {}) => {
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = new URL(url);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw notSmartCdnUrl(`'${url}' is not an absolute URL`);
|
|
189
|
+
}
|
|
190
|
+
const { workspaceSlug, remainder, baseUrl } = locateSmartCdnPath(parsed, options);
|
|
191
|
+
const slashIndex = remainder.indexOf('/');
|
|
192
|
+
if (slashIndex === -1)
|
|
193
|
+
throw notSmartCdnUrl('missing the input segment');
|
|
194
|
+
const templateSlug = remainder.slice(0, slashIndex);
|
|
195
|
+
if (templateSlug === '')
|
|
196
|
+
throw notSmartCdnUrl('missing the template segment');
|
|
197
|
+
const urlParams = {};
|
|
198
|
+
let authKey;
|
|
199
|
+
let expiration;
|
|
200
|
+
let signatureValue;
|
|
201
|
+
for (const [key, value] of new URLSearchParams(parsed.search)) {
|
|
202
|
+
if (key === 'auth_key') {
|
|
203
|
+
authKey = value;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (key === 'exp') {
|
|
207
|
+
expiration = value;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (key === 'sig') {
|
|
211
|
+
signatureValue = value;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const existing = urlParams[key];
|
|
215
|
+
if (existing === undefined)
|
|
216
|
+
urlParams[key] = value;
|
|
217
|
+
else if (Array.isArray(existing))
|
|
218
|
+
existing.push(value);
|
|
219
|
+
else
|
|
220
|
+
urlParams[key] = [existing, value];
|
|
221
|
+
}
|
|
222
|
+
let auth;
|
|
223
|
+
const present = [authKey, expiration, signatureValue].filter((value) => value !== undefined).length;
|
|
224
|
+
if (present > 0) {
|
|
225
|
+
if (authKey === undefined || expiration === undefined || signatureValue === undefined) {
|
|
226
|
+
throw notSmartCdnUrl('incomplete signature parameters; expected auth_key, exp and sig together');
|
|
227
|
+
}
|
|
228
|
+
const expiresAt = Number(expiration);
|
|
229
|
+
if (!Number.isInteger(expiresAt))
|
|
230
|
+
throw notSmartCdnUrl(`exp '${expiration}' is not a timestamp`);
|
|
231
|
+
auth = { key: authKey, expiresAt, signature: signatureValue };
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
workspace: decodeOnce(workspaceSlug, 'the workspace'),
|
|
235
|
+
template: decodeOnce(templateSlug, 'the template'),
|
|
236
|
+
input: decodeOnce(remainder.slice(slashIndex + 1), 'the input'),
|
|
237
|
+
urlParams,
|
|
238
|
+
...(auth && { auth }),
|
|
239
|
+
...(baseUrl != null && { baseUrl }),
|
|
240
|
+
};
|
|
42
241
|
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Maximum requested width or height accepted by the responsive-image Built-ins. */
|
|
2
|
+
export declare const smartCdnImageMaxDimension = 8000;
|
|
3
|
+
/** Image formats supported by the responsive-image Built-in. */
|
|
4
|
+
export type SmartCdnImageFormat = 'avif' | 'png' | 'webp';
|
|
5
|
+
/** Formats and their format-specific quality values. */
|
|
6
|
+
export type SmartCdnImageFormats = Readonly<Partial<Record<SmartCdnImageFormat, number>>>;
|
|
7
|
+
/** One responsive-image candidate at a specific intrinsic width. */
|
|
8
|
+
export interface SmartCdnImageCandidate {
|
|
9
|
+
url: string;
|
|
10
|
+
width: number;
|
|
11
|
+
}
|
|
12
|
+
/** Ordered candidates for one image format and quality. */
|
|
13
|
+
export interface SmartCdnImageSource {
|
|
14
|
+
candidates: readonly SmartCdnImageCandidate[];
|
|
15
|
+
format: SmartCdnImageFormat;
|
|
16
|
+
quality: number;
|
|
17
|
+
}
|
|
18
|
+
/** One validated format and its encoding quality, in browser preference order. */
|
|
19
|
+
export interface SmartCdnImageFormatQuality {
|
|
20
|
+
format: SmartCdnImageFormat;
|
|
21
|
+
quality: number;
|
|
22
|
+
}
|
|
23
|
+
/** Structured data for rendering a responsive image. */
|
|
24
|
+
export interface SmartCdnImageCandidates {
|
|
25
|
+
fallbackUrl: string;
|
|
26
|
+
sources: readonly SmartCdnImageSource[];
|
|
27
|
+
}
|
|
28
|
+
/** Intrinsic dimensions used to prevent upscaling or an oversized derived height. */
|
|
29
|
+
export interface SmartCdnImageSourceDimensions {
|
|
30
|
+
height: number;
|
|
31
|
+
width: number;
|
|
32
|
+
}
|
|
33
|
+
/** One rendition request passed to an injected Smart CDN signer. */
|
|
34
|
+
export interface SmartCdnImageSignRequest {
|
|
35
|
+
expiresAt: number;
|
|
36
|
+
input: string;
|
|
37
|
+
template: string;
|
|
38
|
+
urlParams: Readonly<Record<string, boolean | number | string>>;
|
|
39
|
+
}
|
|
40
|
+
/** Injected signer that keeps responsive-image policy independent from credentials and runtimes. */
|
|
41
|
+
export type SignSmartCdnImageRequest = (request: SmartCdnImageSignRequest) => string;
|
|
42
|
+
/** Framework-neutral options for deterministic Smart CDN image candidates. */
|
|
43
|
+
export interface SmartCdnImagePolicyOptions {
|
|
44
|
+
/** One absolute expiry in milliseconds since UNIX epoch, shared by every candidate. */
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
/** Browser-safe fallback URL, kept separate from the Template-specific input value. */
|
|
47
|
+
fallbackUrl: string;
|
|
48
|
+
/** Formats and their quality values. Defaults to AVIF 45 and WebP 75. */
|
|
49
|
+
formats?: SmartCdnImageFormats;
|
|
50
|
+
/** One source value accepted by the explicitly selected responsive-image Template. */
|
|
51
|
+
input: string;
|
|
52
|
+
/** Intrinsic dimensions, when known, used to keep generated output within backend limits. */
|
|
53
|
+
sourceDimensions?: SmartCdnImageSourceDimensions;
|
|
54
|
+
/** Trusted Template whose source policy is controlled by the caller's workspace. */
|
|
55
|
+
template: string;
|
|
56
|
+
/** Up to 32 intrinsic widths. Each value must be an integer from 1 through 8000. */
|
|
57
|
+
widths: readonly number[];
|
|
58
|
+
}
|
|
59
|
+
/** Resolves and validates format-specific qualities in deterministic browser preference order. */
|
|
60
|
+
export declare function resolveSmartCdnImageFormats(formats: SmartCdnImageFormats | undefined): SmartCdnImageFormatQuality[];
|
|
61
|
+
/** Validates, caps, deduplicates, and sorts requested responsive-image widths. */
|
|
62
|
+
export declare function resolveSmartCdnImageWidths(widths: readonly number[], maximumWidth?: number): number[];
|
|
63
|
+
/**
|
|
64
|
+
* Creates signed responsive-image candidates while leaving credential storage and HMAC choice to
|
|
65
|
+
* the injected signer.
|
|
66
|
+
*/
|
|
67
|
+
export declare function createSmartCdnImageCandidates(options: SmartCdnImagePolicyOptions, sign: SignSmartCdnImageRequest): SmartCdnImageCandidates;
|
|
68
|
+
//# sourceMappingURL=smartCdnImage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"smartCdnImage.d.ts","sourceRoot":"","sources":["../src/smartCdnImage.ts"],"names":[],"mappings":"AAKA,oFAAoF;AACpF,eAAO,MAAM,yBAAyB,OAAO,CAAA;AAE7C,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAA;AAEzD,wDAAwD;AACxD,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC,CAAC,CAAA;AAEzF,oEAAoE;AACpE,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,SAAS,sBAAsB,EAAE,CAAA;IAC7C,MAAM,EAAE,mBAAmB,CAAA;IAC3B,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,kFAAkF;AAClF,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,mBAAmB,CAAA;IAC3B,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,wDAAwD;AACxD,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,SAAS,mBAAmB,EAAE,CAAA;CACxC;AAED,qFAAqF;AACrF,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;CACd;AAED,oEAAoE;AACpE,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAA;CAC/D;AAED,oGAAoG;AACpG,MAAM,MAAM,wBAAwB,GAAG,CAAC,OAAO,EAAE,wBAAwB,KAAK,MAAM,CAAA;AAEpF,8EAA8E;AAC9E,MAAM,WAAW,0BAA0B;IACzC,uFAAuF;IACvF,SAAS,EAAE,MAAM,CAAA;IACjB,uFAAuF;IACvF,WAAW,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,OAAO,CAAC,EAAE,oBAAoB,CAAA;IAC9B,sFAAsF;IACtF,KAAK,EAAE,MAAM,CAAA;IACb,6FAA6F;IAC7F,gBAAgB,CAAC,EAAE,6BAA6B,CAAA;IAChD,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAA;IAChB,oFAAoF;IACpF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAC1B;AA0CD,kGAAkG;AAClG,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,oBAAoB,GAAG,SAAS,GACxC,0BAA0B,EAAE,CAkB9B;AAqBD,kFAAkF;AAClF,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,YAAY,SAA4B,GACvC,MAAM,EAAE,CAeV;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,0BAA0B,EACnC,IAAI,EAAE,wBAAwB,GAC7B,uBAAuB,CAgDzB"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const defaultSmartCdnImageFormats = { avif: 45, webp: 75 };
|
|
2
|
+
const minimumMillisecondTimestamp = 1_000_000_000_000;
|
|
3
|
+
const smartCdnImageFormats = ['avif', 'webp', 'png'];
|
|
4
|
+
const smartCdnImageMaxWidths = 32;
|
|
5
|
+
/** Maximum requested width or height accepted by the responsive-image Built-ins. */
|
|
6
|
+
export const smartCdnImageMaxDimension = 8000;
|
|
7
|
+
function isSmartCdnImageFormat(value) {
|
|
8
|
+
return value === 'avif' || value === 'png' || value === 'webp';
|
|
9
|
+
}
|
|
10
|
+
function validatePositiveSafeInteger(value, name) {
|
|
11
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
12
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function validateSmartCdnImageDimension(value, name) {
|
|
16
|
+
if (!Number.isInteger(value) || value < 1 || value > smartCdnImageMaxDimension) {
|
|
17
|
+
throw new RangeError(`${name} must be an integer from 1 through ${smartCdnImageMaxDimension}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function validateSmartCdnImageQuality(quality) {
|
|
21
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
|
|
22
|
+
throw new RangeError('quality must be an integer from 1 through 100');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function validateSmartCdnImageInput(input) {
|
|
26
|
+
if (typeof input !== 'string' || input === '' || input.trim() !== input || input.includes('|')) {
|
|
27
|
+
throw new TypeError('input must be one non-empty Template input string');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function validateSmartCdnImageFallbackUrl(fallbackUrl) {
|
|
31
|
+
if (typeof fallbackUrl !== 'string' || fallbackUrl === '' || fallbackUrl.trim() !== fallbackUrl) {
|
|
32
|
+
throw new TypeError('fallbackUrl must be a non-empty string without surrounding whitespace');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function validateSmartCdnImageTemplate(template) {
|
|
36
|
+
if (typeof template !== 'string' || template === '' || template.trim() !== template) {
|
|
37
|
+
throw new TypeError('template must be a non-empty string without surrounding whitespace');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Resolves and validates format-specific qualities in deterministic browser preference order. */
|
|
41
|
+
export function resolveSmartCdnImageFormats(formats) {
|
|
42
|
+
const resolved = formats ?? defaultSmartCdnImageFormats;
|
|
43
|
+
for (const format of Object.keys(resolved)) {
|
|
44
|
+
if (!isSmartCdnImageFormat(format)) {
|
|
45
|
+
throw new TypeError(`Unsupported Smart CDN image format: ${format}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const selected = [];
|
|
49
|
+
for (const format of smartCdnImageFormats) {
|
|
50
|
+
if (!Object.hasOwn(resolved, format))
|
|
51
|
+
continue;
|
|
52
|
+
const quality = resolved[format];
|
|
53
|
+
if (quality === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
validateSmartCdnImageQuality(quality);
|
|
56
|
+
selected.push({ format, quality });
|
|
57
|
+
}
|
|
58
|
+
if (selected.length === 0)
|
|
59
|
+
throw new TypeError('formats must contain at least one value');
|
|
60
|
+
return selected;
|
|
61
|
+
}
|
|
62
|
+
function getMaximumCandidateWidth(sourceDimensions) {
|
|
63
|
+
if (sourceDimensions === undefined)
|
|
64
|
+
return smartCdnImageMaxDimension;
|
|
65
|
+
validatePositiveSafeInteger(sourceDimensions.width, 'sourceDimensions.width');
|
|
66
|
+
validatePositiveSafeInteger(sourceDimensions.height, 'sourceDimensions.height');
|
|
67
|
+
const heightLimitedWidth = Number((BigInt(smartCdnImageMaxDimension) * BigInt(sourceDimensions.width)) /
|
|
68
|
+
BigInt(sourceDimensions.height));
|
|
69
|
+
if (heightLimitedWidth < 1) {
|
|
70
|
+
// Even a one-pixel-wide rendition would exceed the backend height limit; no truthful candidate
|
|
71
|
+
// can preserve this aspect ratio.
|
|
72
|
+
throw new RangeError('sourceDimensions aspect ratio cannot fit within backend dimensions');
|
|
73
|
+
}
|
|
74
|
+
return Math.min(smartCdnImageMaxDimension, sourceDimensions.width, heightLimitedWidth);
|
|
75
|
+
}
|
|
76
|
+
/** Validates, caps, deduplicates, and sorts requested responsive-image widths. */
|
|
77
|
+
export function resolveSmartCdnImageWidths(widths, maximumWidth = smartCdnImageMaxDimension) {
|
|
78
|
+
if (!Array.isArray(widths) || widths.length === 0) {
|
|
79
|
+
throw new TypeError('widths must contain at least one value');
|
|
80
|
+
}
|
|
81
|
+
if (widths.length > smartCdnImageMaxWidths) {
|
|
82
|
+
throw new RangeError(`widths must contain at most ${smartCdnImageMaxWidths} values`);
|
|
83
|
+
}
|
|
84
|
+
validateSmartCdnImageDimension(maximumWidth, 'maximumWidth');
|
|
85
|
+
const candidates = new Set();
|
|
86
|
+
for (const [index, width] of widths.entries()) {
|
|
87
|
+
validateSmartCdnImageDimension(width, `widths[${index}]`);
|
|
88
|
+
candidates.add(Math.min(width, maximumWidth));
|
|
89
|
+
}
|
|
90
|
+
return [...candidates].sort((left, right) => left - right);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Creates signed responsive-image candidates while leaving credential storage and HMAC choice to
|
|
94
|
+
* the injected signer.
|
|
95
|
+
*/
|
|
96
|
+
export function createSmartCdnImageCandidates(options, sign) {
|
|
97
|
+
const expiresAt = options.expiresAt;
|
|
98
|
+
const fallbackUrl = options.fallbackUrl;
|
|
99
|
+
const formatOptions = options.formats;
|
|
100
|
+
const formatsSnapshot = formatOptions === undefined ? undefined : { ...formatOptions };
|
|
101
|
+
const input = options.input;
|
|
102
|
+
const sourceDimensionOptions = options.sourceDimensions;
|
|
103
|
+
const sourceDimensions = sourceDimensionOptions === undefined
|
|
104
|
+
? undefined
|
|
105
|
+
: { height: sourceDimensionOptions.height, width: sourceDimensionOptions.width };
|
|
106
|
+
const template = options.template;
|
|
107
|
+
const widthOptions = options.widths;
|
|
108
|
+
const widthsSnapshot = Array.isArray(widthOptions) ? [...widthOptions] : widthOptions;
|
|
109
|
+
validatePositiveSafeInteger(expiresAt, 'expiresAt');
|
|
110
|
+
if (expiresAt < minimumMillisecondTimestamp) {
|
|
111
|
+
throw new RangeError('expiresAt must be a millisecond timestamp');
|
|
112
|
+
}
|
|
113
|
+
validateSmartCdnImageFallbackUrl(fallbackUrl);
|
|
114
|
+
validateSmartCdnImageInput(input);
|
|
115
|
+
validateSmartCdnImageTemplate(template);
|
|
116
|
+
if (typeof sign !== 'function')
|
|
117
|
+
throw new TypeError('sign must be a function');
|
|
118
|
+
const formats = resolveSmartCdnImageFormats(formatsSnapshot);
|
|
119
|
+
const widths = resolveSmartCdnImageWidths(widthsSnapshot, getMaximumCandidateWidth(sourceDimensions));
|
|
120
|
+
const sources = [];
|
|
121
|
+
for (const { format, quality } of formats) {
|
|
122
|
+
sources.push({
|
|
123
|
+
candidates: widths.map((width) => ({
|
|
124
|
+
url: sign({
|
|
125
|
+
expiresAt,
|
|
126
|
+
input,
|
|
127
|
+
template,
|
|
128
|
+
urlParams: { f: format, q: quality, r: 'fit', w: width },
|
|
129
|
+
}),
|
|
130
|
+
width,
|
|
131
|
+
})),
|
|
132
|
+
format,
|
|
133
|
+
quality,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return { fallbackUrl, sources };
|
|
137
|
+
}
|