@transcodely/sdk 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/dist/index.cjs +16246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +10920 -0
- package/dist/index.d.ts +10920 -0
- package/dist/index.mjs +16180 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +66 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Transcodely JavaScript / TypeScript SDK will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Breaking changes are allowed on minor bumps until 1.0.0.
|
|
6
|
+
|
|
7
|
+
## [0.1.2](https://github.com/transcodely/transcodely-js/compare/v0.1.1...v0.1.2) (2026-07-01)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **origins:** add Cloudflare R2 as a first-class provider ([#4](https://github.com/transcodely/transcodely-js/issues/4)) ([6807153](https://github.com/transcodely/transcodely-js/commit/680715334e43a4b9968df52ab1a8d1c86fb0ac03))
|
|
13
|
+
* **webhooks:** add verify helper, typed events, and endpoint resources ([#6](https://github.com/transcodely/transcodely-js/issues/6)) ([97a4f3a](https://github.com/transcodely/transcodely-js/commit/97a4f3ade9ab0e7e90abf09eb414d03526e78d96))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **webhooks:** expand simplified enums when decoding event data ([#7](https://github.com/transcodely/transcodely-js/issues/7)) ([f05b487](https://github.com/transcodely/transcodely-js/commit/f05b487d72a77aa831e580591eebbc6b14e8af7b))
|
|
19
|
+
|
|
20
|
+
## [0.1.1](https://github.com/transcodely/transcodely-js/compare/v0.1.0...v0.1.1) (2026-05-05)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* initial 0.1.0 alpha release ([0f1d870](https://github.com/transcodely/transcodely-js/commit/0f1d87035d3dc510510bbdb5df1643089d6db6e0))
|
|
26
|
+
|
|
27
|
+
## [0.1.0] — Alpha
|
|
28
|
+
|
|
29
|
+
Initial public alpha. Covers 100% of the public RPC surface (56 RPCs across 10 services). Stripe-style facade: lazy resource namespaces, auto-pagination, auto-idempotency on `create` mutations, typed error hierarchy (1 base + 8 concrete), Watch streams with auto-reconnect, calendar-versioned API.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Transcodely
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# @transcodely/sdk
|
|
2
|
+
|
|
3
|
+
Official TypeScript / Node SDK for the [Transcodely](https://transcodely.com) video transcoding API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @transcodely/sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Transcodely, OutputFormat, VideoCodec, Resolution } from "@transcodely/sdk";
|
|
13
|
+
|
|
14
|
+
const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });
|
|
15
|
+
|
|
16
|
+
// Create a job
|
|
17
|
+
const job = await client.jobs.create({
|
|
18
|
+
inputUrl: "https://example.com/source.mp4",
|
|
19
|
+
outputs: [{
|
|
20
|
+
type: OutputFormat.HLS,
|
|
21
|
+
video: [
|
|
22
|
+
{ codec: VideoCodec.H264, resolution: Resolution.RESOLUTION_1080P },
|
|
23
|
+
{ codec: VideoCodec.H264, resolution: Resolution.RESOLUTION_720P },
|
|
24
|
+
],
|
|
25
|
+
}],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
console.log(job.id); // "job_a1b2c3d4e5f6"
|
|
29
|
+
|
|
30
|
+
// Watch progress in real time
|
|
31
|
+
for await (const event of client.jobs.watch(job.id)) {
|
|
32
|
+
console.log(event.job?.status, event.job?.progress);
|
|
33
|
+
if (event.job?.status === 4 /* COMPLETED */) break;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Authentication
|
|
38
|
+
|
|
39
|
+
Pass your API key in the constructor:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Test-mode keys (`ak_test_*`) and live-mode keys (`ak_live_*`) hit the same base URL — the environment is encoded in the key prefix.
|
|
46
|
+
|
|
47
|
+
## Resources
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
client.jobs // create / get / list / cancel / confirm / watch
|
|
51
|
+
client.videos // upload helpers, multipart, get / list / update / delete / watch
|
|
52
|
+
client.presets // create / get / getBySlug / list / update / duplicate / archive
|
|
53
|
+
client.origins // create / get / list / update / validate / archive
|
|
54
|
+
client.apps // create / get / list / update / archive / enableHosting
|
|
55
|
+
client.apiKeys // create / get / list / revoke
|
|
56
|
+
client.organizations // create / get / list / update / checkSlug
|
|
57
|
+
client.memberships // list / get / updateRole / remove
|
|
58
|
+
client.users // getMe / get / list / updateMe
|
|
59
|
+
client.health // check
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Origins
|
|
63
|
+
|
|
64
|
+
An origin tells Transcodely where to read source media from and where to write outputs. Every origin belongs to a single provider; pass exactly one provider-config field (`s3`, `gcs`, `http`, or `r2`) on create.
|
|
65
|
+
|
|
66
|
+
### Create an S3 origin
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { Transcodely, OriginPermission } from "@transcodely/sdk";
|
|
70
|
+
|
|
71
|
+
const origin = await client.origins.create({
|
|
72
|
+
name: "Production S3",
|
|
73
|
+
permissions: [OriginPermission.READ, OriginPermission.WRITE],
|
|
74
|
+
s3: {
|
|
75
|
+
bucket: "my-bucket",
|
|
76
|
+
region: "us-east-1",
|
|
77
|
+
credentials: {
|
|
78
|
+
accessKeyId: process.env.S3_ACCESS_KEY!,
|
|
79
|
+
secretAccessKey: process.env.S3_SECRET_KEY!,
|
|
80
|
+
},
|
|
81
|
+
// endpoint: "https://s3.custom.example.com", // for MinIO, Wasabi, etc.
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Create a GCS origin
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { Transcodely, OriginPermission } from "@transcodely/sdk";
|
|
90
|
+
|
|
91
|
+
const origin = await client.origins.create({
|
|
92
|
+
name: "Production GCS",
|
|
93
|
+
permissions: [OriginPermission.READ, OriginPermission.WRITE],
|
|
94
|
+
gcs: {
|
|
95
|
+
bucket: "my-gcs-bucket",
|
|
96
|
+
credentials: {
|
|
97
|
+
serviceAccountJson: process.env.GCS_SERVICE_ACCOUNT_JSON!,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Create an HTTP origin
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { Transcodely, OriginPermission } from "@transcodely/sdk";
|
|
107
|
+
|
|
108
|
+
const origin = await client.origins.create({
|
|
109
|
+
name: "Public CDN",
|
|
110
|
+
permissions: [OriginPermission.READ], // HTTP origins are read-only
|
|
111
|
+
http: {
|
|
112
|
+
baseUrl: "https://media.example.com",
|
|
113
|
+
credentials: {
|
|
114
|
+
headers: { Authorization: `Bearer ${process.env.MEDIA_TOKEN!}` },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Create an R2 origin
|
|
121
|
+
|
|
122
|
+
R2 supports two forms. With `accountId` (32-char hex) the endpoint is derived for you, optionally with a data-residency jurisdiction:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { Transcodely, OriginPermission, R2Jurisdiction } from "@transcodely/sdk";
|
|
126
|
+
|
|
127
|
+
const origin = await client.origins.create({
|
|
128
|
+
name: "Production R2",
|
|
129
|
+
permissions: [OriginPermission.READ, OriginPermission.WRITE],
|
|
130
|
+
r2: {
|
|
131
|
+
bucket: "media",
|
|
132
|
+
accountId: process.env.R2_ACCOUNT_ID!,
|
|
133
|
+
jurisdiction: R2Jurisdiction.DEFAULT, // or .EU, .FEDRAMP
|
|
134
|
+
credentials: {
|
|
135
|
+
accessKeyId: process.env.R2_ACCESS_KEY!,
|
|
136
|
+
secretAccessKey: process.env.R2_SECRET_KEY!,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Or, with an explicit `endpoint` (custom domain bound to a bucket, or a jurisdiction not yet enumerated):
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
r2: {
|
|
146
|
+
bucket: "media",
|
|
147
|
+
endpoint: "https://media.example.com",
|
|
148
|
+
credentials: {
|
|
149
|
+
accessKeyId: process.env.R2_ACCESS_KEY!,
|
|
150
|
+
secretAccessKey: process.env.R2_SECRET_KEY!,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Provide either `accountId` or `endpoint`, never both. `jurisdiction` only applies when `accountId` is set.
|
|
156
|
+
|
|
157
|
+
## Webhooks
|
|
158
|
+
|
|
159
|
+
Transcodely signs every webhook delivery with HMAC-SHA-256 using your endpoint's `whsec_…` secret. Verify the signature before trusting the body — `client.webhooks.constructEvent` validates the signature, parses the envelope, and returns a typed event:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import express from "express";
|
|
163
|
+
import { Transcodely, WebhookSignatureError, WebhookTimestampError } from "@transcodely/sdk";
|
|
164
|
+
|
|
165
|
+
const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });
|
|
166
|
+
const app = express();
|
|
167
|
+
|
|
168
|
+
app.post(
|
|
169
|
+
"/webhooks/transcodely",
|
|
170
|
+
express.raw({ type: "application/json" }),
|
|
171
|
+
(req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const event = client.webhooks.constructEvent(
|
|
174
|
+
req.body,
|
|
175
|
+
req.header("transcodely-signature")!,
|
|
176
|
+
process.env.WEBHOOK_SECRET!,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
switch (event.type) {
|
|
180
|
+
case "job.succeeded":
|
|
181
|
+
// event.data is a fully-typed Job
|
|
182
|
+
console.log("Job done:", event.data.id);
|
|
183
|
+
break;
|
|
184
|
+
case "video.uploaded":
|
|
185
|
+
console.log("Video uploaded:", event.data.id);
|
|
186
|
+
break;
|
|
187
|
+
default:
|
|
188
|
+
// Forward-compat: unknown types still parse.
|
|
189
|
+
console.log("Unhandled:", event.type);
|
|
190
|
+
}
|
|
191
|
+
res.sendStatus(200);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof WebhookSignatureError || err instanceof WebhookTimestampError) {
|
|
194
|
+
res.sendStatus(400);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The signed payload is the **raw HTTP body** — use `express.raw()` (or the equivalent in your framework) to receive a `Buffer`, never `express.json()`.
|
|
204
|
+
|
|
205
|
+
### Multi-secret rotation
|
|
206
|
+
|
|
207
|
+
Pass an array to verify against both your previous and current secrets during a rotation window:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
client.webhooks.constructEvent(body, sig, [process.env.PREVIOUS_SECRET!, process.env.CURRENT_SECRET!]);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Manage endpoints
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const endpoint = await client.webhookEndpoints.create({
|
|
217
|
+
appId: "app_xyz",
|
|
218
|
+
url: "https://example.com/webhooks/transcodely",
|
|
219
|
+
enabledEvents: ["job.succeeded", "job.failed", "video.uploaded"],
|
|
220
|
+
});
|
|
221
|
+
console.log("Store this:", endpoint.secret); // only present on create + rotate
|
|
222
|
+
|
|
223
|
+
const rotated = await client.webhookEndpoints.rotateSecret(endpoint.id);
|
|
224
|
+
console.log("New secret:", rotated.secret);
|
|
225
|
+
|
|
226
|
+
for await (const ep of client.webhookEndpoints.list({ appId: "app_xyz" }).autoPage()) {
|
|
227
|
+
console.log(ep.id, ep.url);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
await client.webhookEndpoints.sendTest(endpoint.id, "job.succeeded");
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Replay an event
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// Fetch a stored event (same shape as constructEvent returns)
|
|
237
|
+
const event = await client.events.retrieve("evt_…");
|
|
238
|
+
console.log(event.type, event.data);
|
|
239
|
+
|
|
240
|
+
// Requeue delivery — defaults to every subscribed endpoint, or pass
|
|
241
|
+
// `endpointIds` to target a subset.
|
|
242
|
+
await client.events.resend("evt_…");
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Errors
|
|
246
|
+
|
|
247
|
+
All SDK errors extend `TranscodelyError`:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { TranscodelyError, InvalidRequestError, RateLimitError } from "@transcodely/sdk";
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await client.jobs.create(params);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (err instanceof InvalidRequestError) {
|
|
256
|
+
for (const v of err.errors) console.warn(`${v.field}: ${v.description}`);
|
|
257
|
+
} else if (err instanceof RateLimitError) {
|
|
258
|
+
await new Promise((r) => setTimeout(r, err.retryAfterMs ?? 1000));
|
|
259
|
+
} else if (err instanceof TranscodelyError) {
|
|
260
|
+
console.error(err.code, err.message, err.requestId);
|
|
261
|
+
} else {
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The hierarchy:
|
|
268
|
+
|
|
269
|
+
| Class | Status | When |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `APIConnectionError` | — | Network / DNS / TLS failure |
|
|
272
|
+
| `APIError` | 5xx | Server-side error |
|
|
273
|
+
| `AuthenticationError` | 401 | Bad / missing / revoked key |
|
|
274
|
+
| `PermissionError` | 403 | Authenticated but forbidden |
|
|
275
|
+
| `NotFoundError` | 404 | Resource doesn't exist |
|
|
276
|
+
| `ConflictError` | 409 | Idempotency conflict, slug taken |
|
|
277
|
+
| `RateLimitError` | 429 | Carries `retryAfterMs` |
|
|
278
|
+
| `InvalidRequestError` | 400 | Carries `errors` (`FieldViolation[]`) |
|
|
279
|
+
| `PreconditionError` | 412 | Wrong state (e.g. job not cancelable) |
|
|
280
|
+
|
|
281
|
+
Every error carries `requestId`, `code`, `httpStatus`, and `raw` for debugging.
|
|
282
|
+
|
|
283
|
+
## Pagination
|
|
284
|
+
|
|
285
|
+
Every `list` method returns a `Page` you can either await for one page or auto-iterate:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
// One page
|
|
289
|
+
const page = await client.jobs.list({ pagination: { limit: 50 } });
|
|
290
|
+
console.log(page.items, page.nextCursor);
|
|
291
|
+
|
|
292
|
+
// All items, automatically across pages
|
|
293
|
+
for await (const job of client.jobs.list({ pagination: { limit: 50 } }).autoPage()) {
|
|
294
|
+
console.log(job.id);
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Idempotency
|
|
299
|
+
|
|
300
|
+
`jobs.create` accepts an `idempotencyKey` field. The SDK auto-generates a UUID if you don't pass one, so retries are always safe. For cross-process safety, pass your own:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
await client.jobs.create({
|
|
304
|
+
inputUrl: "...",
|
|
305
|
+
outputs: [...],
|
|
306
|
+
idempotencyKey: "create-job-for-asset-12345",
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
For all other write methods, the SDK ships `Idempotency-Key` HTTP header automatically.
|
|
311
|
+
|
|
312
|
+
## Streaming watch
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
const ac = new AbortController();
|
|
316
|
+
setTimeout(() => ac.abort(), 30_000); // give up after 30s
|
|
317
|
+
|
|
318
|
+
for await (const event of client.jobs.watch(job.id, { signal: ac.signal })) {
|
|
319
|
+
console.log(event.event, event.job?.status, event.job?.progress);
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The SDK auto-reconnects on transient network failures (Watch is read-only, so resumption is idempotent — every reconnect emits a fresh `SNAPSHOT` event). Heartbeat events are filtered by default; pass `includeHeartbeats: true` to see them.
|
|
324
|
+
|
|
325
|
+
## Configuration
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
new Transcodely({
|
|
329
|
+
apiKey: string, // required
|
|
330
|
+
baseUrl?: string, // default: https://api.transcodely.com
|
|
331
|
+
timeoutMs?: number, // unary-call timeout, default 30s
|
|
332
|
+
maxRetries?: number, // default 3
|
|
333
|
+
apiVersion?: string, // override the pinned API version
|
|
334
|
+
defaultHeaders?: Record<string, string>, // sent on every request
|
|
335
|
+
fetchImpl?: typeof fetch, // for browser DI / testing
|
|
336
|
+
logger?: (event: LogEvent) => void, // structured request logger
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Request IDs
|
|
341
|
+
|
|
342
|
+
Each response carries `X-Request-Id`. Stripe-style:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
console.log(client.lastRequestId); // "req_*"
|
|
346
|
+
|
|
347
|
+
try { await client.jobs.create(...); }
|
|
348
|
+
catch (err) {
|
|
349
|
+
if (err instanceof TranscodelyError) console.error("failed:", err.requestId);
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Wire format
|
|
354
|
+
|
|
355
|
+
The SDK uses Connect-RPC over HTTP+JSON with snake_case field names and lowercase simplified enum values (e.g. `"pending"` instead of `"JOB_STATUS_PENDING"`). A custom codec handles the transformation transparently — the surface you write against is fully typed.
|
|
356
|
+
|
|
357
|
+
## Versioning
|
|
358
|
+
|
|
359
|
+
The SDK is versioned independently with semver, starting at `0.1.0`. Breaking changes are allowed on minor bumps until `1.0.0`. Each release pins a specific calendar-versioned API (`Transcodely.API_VERSION`) and sends `Transcodely-Version` on every request.
|
|
360
|
+
|
|
361
|
+
## License
|
|
362
|
+
|
|
363
|
+
[MIT](LICENSE).
|