Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@thelevicole/youtube-to-html5-loader

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@thelevicole/youtube-to-html5-loader - npm Package Compare versions

Comparing version 2.0.0 to 3.0.0

2

dist/YouTubeToHtml5.js

@@ -1,1 +0,1 @@

'use strict';function _typeof(a){"@babel/helpers - typeof";return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(a){return typeof a}:function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a},_typeof(a)}function YouTubeToHtml5(){var a=0<arguments.length&&arguments[0]!==void 0?arguments[0]:{};for(var b in this.options)b in a&&(this.options[b]=a[b]);this.options.autoload&&this.load()}YouTubeToHtml5.prototype.options={selector:"video[data-yt2html5]",attribute:"data-yt2html5",formats:["1080p","720p","360p"],autoload:!0},YouTubeToHtml5.prototype.hooks={filters:[],actions:[]},YouTubeToHtml5.prototype.getHooks=function(a,b){if(a in this.hooks){var c=this.hooks[a].sort(function(c,a){return c.priority-a.priority});return c.filter(function(a){return a.name===b})}return[]},YouTubeToHtml5.prototype.addAction=function(a,b){var c=2<arguments.length&&arguments[2]!==void 0?arguments[2]:10;this.hooks.actions.push({name:a,callback:b,priority:c})},YouTubeToHtml5.prototype.doAction=function(a){for(var b=this.getHooks("actions",a),c=arguments.length,d=Array(1<c?c-1:0),e=1;e<c;e++)d[e-1]=arguments[e];for(var f=0;f<b.length;f++){var g;(g=b[f]).callback.apply(g,d)}},YouTubeToHtml5.prototype.addFilter=function(a,b){var c=2<arguments.length&&arguments[2]!==void 0?arguments[2]:10;this.hooks.filters.push({name:a,callback:b,priority:c})},YouTubeToHtml5.prototype.applyFilters=function(a,b){for(var c=this.getHooks("filters",a),d=arguments.length,e=Array(2<d?d-2:0),f=2;f<d;f++)e[f-2]=arguments[f];for(var g=0;g<c.length;g++){var h;b=(h=c[g]).callback.apply(h,[b].concat(e))}return b},YouTubeToHtml5.prototype.itagMap={18:"360p",22:"720p",37:"1080p",38:"3072p",82:"360p3d",83:"480p3d",84:"720p3d",85:"1080p3d",133:"240pna",134:"360pna",135:"480pna",136:"720pna",137:"1080pna",264:"1440pna",298:"720p60",299:"1080p60na",160:"144pna",139:"48kbps",140:"128kbps",141:"256kbps"},YouTubeToHtml5.prototype.urlToId=function(a){var b=a.match(/^(?:http(?:s)?:\/\/)?(?:www\.)?(?:m\.)?(?:youtu\.be\/|(?:(?:youtube-nocookie\.com\/|youtube\.com\/)(?:(?:watch)?\?(?:.*&)?v(?:i)?=|(?:embed|v|vi|user)\/)))([a-zA-Z0-9\-_]*)/);return Array.isArray(b)&&b[1]?b[1]:a},YouTubeToHtml5.prototype.fetch=function(a){return new Promise(function(b,c){var d=new XMLHttpRequest;d.open("GET",a,!0),d.onreadystatechange=function(){4===this.readyState&&(200<=this.status&&400>this.status?b(this.responseText):c(this))},d.send(),d=null})},YouTubeToHtml5.prototype.getElements=function(a){var b=null;return a&&(NodeList.prototype.isPrototypeOf(a)||HTMLCollection.prototype.isPrototypeOf(a)?b=a:"object"===_typeof(a)&&"nodeType"in a&&a.nodeType?b=[a]:b=document.querySelectorAll(this.options.selector)),b=Array.from(b||""),this.applyFilters("elements",b)},YouTubeToHtml5.prototype.youtubeDataApiEndpoint=function(a){var b=~~(33*Math.random()),c="https://images".concat(b,"-focus-opensocial.googleusercontent.com/gadgets/proxy?container=none&url=https%3A%2F%2Fwww.youtube.com%2Fget_video_info%3Fvideo_id%3D").concat(a);return this.applyFilters("youtube.api.endpoint",c,a,b)},YouTubeToHtml5.prototype.parseUriString=function(a){return a.split("&").reduce(function(a,b){var c=b.split("=").map(function(a){return decodeURIComponent(a.replace("+"," "))});return a[c[0]]=c[1],a},{})},YouTubeToHtml5.prototype.parseYoutubeMeta=function(a){var b=this,c=[],d={},e=this.parseUriString(a);return e.player_response=JSON.parse(e.player_response),e.fflags=this.parseUriString(e.fflags),e=this.applyFilters("youtube.meta",e,a),e.hasOwnProperty("url_encoded_fmt_stream_map")?c=c.concat(e.url_encoded_fmt_stream_map.split(",").map(function(a){return b.parseUriString(a)})):e.player_response.streamingData&&e.player_response.streamingData.formats?c=c.concat(e.player_response.streamingData.formats):e.hasOwnProperty("adaptive_fmts")?c=c.concat(e.adaptive_fmts.split(",").map(function(a){return b.parseUriString(a)})):e.player_response.streamingData&&e.player_response.streamingData.adaptiveFormats&&(c=c.concat(e.player_response.streamingData.adaptiveFormats)),c.forEach(function(a){b.itagMap[a.itag]&&a.url&&(d[b.itagMap[a.itag]]=a.url)}),d},YouTubeToHtml5.prototype.load=function(){var a=this,b=this.getElements(this.options.selector);b&&b.length&&b.forEach(function(b){a.loadSingle(b)})},YouTubeToHtml5.prototype.loadSingle=function(a){var b=this,c=1<arguments.length&&arguments[1]!==void 0?arguments[1]:null,d=c||this.options.attribute;if(a.getAttribute(d)){var e=this.urlToId(a.getAttribute(d)),f=this.youtubeDataApiEndpoint(e);this.doAction("api.before",a),this.fetch(f).then(function(c){if(c){var d=b.parseYoutubeMeta(c);if(d&&b.options.formats)for(var e,f=0;f<b.options.formats.length;f++)if(e=b.options.formats[f],e in d){a.src=b.applyFilters("video.source",d[e],a,e,d);break}}})["finally"](function(c){b.doAction("api.after",a,c)})}},"object"===("undefined"==typeof module?"undefined":_typeof(module))&&"object"===_typeof(module.exports)&&(module.exports=YouTubeToHtml5);
'use strict';function _typeof(a){"@babel/helpers - typeof";return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(a){return typeof a}:function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a},_typeof(a)}function YouTubeToHtml5(){var a=0<arguments.length&&arguments[0]!==void 0?arguments[0]:{};for(var b in this.options)b in a&&(this.options[b]=a[b]);this.options.autoload&&this.load()}YouTubeToHtml5.prototype.options={selector:"video[data-yt2html5]",attribute:"data-yt2html5",formats:"*",autoload:!0},YouTubeToHtml5.prototype.hooks={filters:[],actions:[]},YouTubeToHtml5.prototype.getHooks=function(a,b){if(a in this.hooks){var c=this.hooks[a].sort(function(c,a){return c.priority-a.priority});return c.filter(function(a){return a.name===b})}return[]},YouTubeToHtml5.prototype.addAction=function(a,b){var c=2<arguments.length&&arguments[2]!==void 0?arguments[2]:10;this.hooks.actions.push({name:a,callback:b,priority:c})},YouTubeToHtml5.prototype.doAction=function(a){for(var b=this.getHooks("actions",a),c=arguments.length,d=Array(1<c?c-1:0),e=1;e<c;e++)d[e-1]=arguments[e];for(var f=0;f<b.length;f++){var g;(g=b[f]).callback.apply(g,d)}},YouTubeToHtml5.prototype.addFilter=function(a,b){var c=2<arguments.length&&arguments[2]!==void 0?arguments[2]:10;this.hooks.filters.push({name:a,callback:b,priority:c})},YouTubeToHtml5.prototype.applyFilters=function(a,b){for(var c=this.getHooks("filters",a),d=arguments.length,e=Array(2<d?d-2:0),f=2;f<d;f++)e[f-2]=arguments[f];for(var g=0;g<c.length;g++){var h;b=(h=c[g]).callback.apply(h,[b].concat(e))}return b},YouTubeToHtml5.prototype.itagMap={18:"360p",22:"720p",37:"1080p",38:"3072p",82:"360p3d",83:"480p3d",84:"720p3d",85:"1080p3d",133:"240pna",134:"360pna",135:"480pna",136:"720pna",137:"1080pna",264:"1440pna",298:"720p60",299:"1080p60na",160:"144pna",139:"48kbps",140:"128kbps",141:"256kbps"},YouTubeToHtml5.prototype.urlToId=function(a){var b=a.match(/^(?:http(?:s)?:\/\/)?(?:www\.)?(?:m\.)?(?:youtu\.be\/|(?:(?:youtube-nocookie\.com\/|youtube\.com\/)(?:(?:watch)?\?(?:.*&)?v(?:i)?=|(?:embed|v|vi|user)\/)))([a-zA-Z0-9\-_]*)/);return Array.isArray(b)&&b[1]?b[1]:a},YouTubeToHtml5.prototype.fetch=function(a){return new Promise(function(b,c){var d=new XMLHttpRequest;d.open("GET",a,!0),d.onreadystatechange=function(){4===this.readyState&&(200<=this.status&&400>this.status?b(this.responseText):c(this))},d.send(),d=null})},YouTubeToHtml5.prototype.getAllowedFormats=function(){var a=[];return Array.isArray(this.options.formats)?a=this.options.formats:this.itagMap[this.options.formats]?a=[this.options.formats]:"*"===this.options.formats&&(a=Object.values(this.itagMap).sort()),a},YouTubeToHtml5.prototype.getElements=function(a){var b=null;return a&&(NodeList.prototype.isPrototypeOf(a)||HTMLCollection.prototype.isPrototypeOf(a)?b=a:"object"===_typeof(a)&&"nodeType"in a&&a.nodeType?b=[a]:b=document.querySelectorAll(this.options.selector)),b=Array.from(b||""),this.applyFilters("elements",b)},YouTubeToHtml5.prototype.youtubeDataApiEndpoint=function(a){var b=~~(33*Math.random()),c="https://images".concat(b,"-focus-opensocial.googleusercontent.com/gadgets/proxy?container=none&url=https%3A%2F%2Fwww.youtube.com%2Fget_video_info%3Fvideo_id%3D").concat(a);return this.applyFilters("api.endpoint",c,a,b)},YouTubeToHtml5.prototype.parseUriString=function(a){return a.split("&").reduce(function(a,b){var c=b.split("=").map(function(a){return decodeURIComponent(a.replace("+"," "))});return a[c[0]]=c[1],a},{})},YouTubeToHtml5.prototype.parseYoutubeMeta=function(a){var b=this,c=[],d={},e=this.parseUriString(a);return e.player_response=JSON.parse(e.player_response),e.fflags=this.parseUriString(e.fflags),e=this.applyFilters("api.response",e,a),e.hasOwnProperty("url_encoded_fmt_stream_map")?c=c.concat(e.url_encoded_fmt_stream_map.split(",").map(function(a){return b.parseUriString(a)})):e.hasOwnProperty("adaptive_fmts")?c=c.concat(e.adaptive_fmts.split(",").map(function(a){return b.parseUriString(a)})):e.player_response.streamingData&&e.player_response.streamingData.adaptiveFormats?c=c.concat(e.player_response.streamingData.adaptiveFormats):e.player_response.streamingData&&e.player_response.streamingData.formats&&(c=c.concat(e.player_response.streamingData.formats)),c.forEach(function(a){if(b.itagMap[a.itag]&&a.url){var c="unknown",e="unknown";if("mimeType"in a){var f=a.mimeType.match(/^(audio|video)(?:\/([^;]+);)?/i);f[1]&&(c=f[1]),f[2]&&(e=f[2])}d[b.itagMap[a.itag]]={url:a.url,label:a.qualityLabel||b.itagMap[a.itag],type:c,mime:e}}}),d=this.applyFilters("api.results",d,e),d},YouTubeToHtml5.prototype.load=function(){var a=this,b=this.getElements(this.options.selector);b&&b.length&&b.forEach(function(b){a.loadSingle(b)})},YouTubeToHtml5.prototype.loadSingle=function(a){var b=this,c=1<arguments.length&&arguments[1]!==void 0?arguments[1]:null,d=c||this.options.attribute;if(a.getAttribute(d)){var e=this.urlToId(a.getAttribute(d)),f=this.youtubeDataApiEndpoint(e);this.doAction("api.before",a),this.fetch(f).then(function(c){if(c){var d=b.parseYoutubeMeta(c);if(d){for(var e,f=b.getAllowedFormats(),g=null,h=null,j=0;j<f.length;j++)if(e=f[j],e in d){g=d[e],h=e;break}g=b.applyFilters("video.stream",g,a,h,d),a.src=b.applyFilters("video.source",g.url,g,a,h,d)}}})["finally"](function(c){b.doAction("api.after",a,c)})}},"object"===("undefined"==typeof module?"undefined":_typeof(module))&&"object"===_typeof(module.exports)&&(module.exports=YouTubeToHtml5);
{
"name": "@thelevicole/youtube-to-html5-loader",
"version": "2.0.0",
"version": "3.0.0",
"description": "A javascript library to load YoutTube videos as HTML5 emebed elements.",

@@ -5,0 +5,0 @@ "main": "dist/YouTubeToHtml5.js",

@@ -8,46 +8,70 @@ # Load YoutTube videos as HTML5 emebed element

Replacing `YOUTUBE_URL_OR_ID_GOES_HERE` with your video URL or ID.
```html
<video data-yt2html5="YOUTUBE_URL_OR_ID_GOES_HERE"></video>
<script src="YouTubeToHtml5.js"></script>
<script>new YouTubeToHtml5();</script>
```
## Options example
```html
<video class="youtube-video" data-yt="https://youtube.com/watch?v=ScMzIvxBSi4"></video>
<script src="YouTubeToHtml5.js"></script>
<script>new YouTubeToHtml5( {
selector: '.youtube-video',
attribute: 'data-yt'
} );</script>
```
## Internal API example
```html
<video data-yt2html5="YOUTUBE_URL_OR_ID_GOES_HERE"></video>
<script src="YouTubeToHtml5.js"></script>
<script>
var player = new YouTubeToHtml5( {
autoload: false
} );
player.addAction( 'api.before', function( element ) {
element.classList.add( 'is-loading' );
} );
```html
<video data-yt2html5="YOUTUBE_URL_OR_ID_GOES_HERE"></video>
<script src="YouTubeToHtml5.js"></script>
<script>new YouTubeToHtml5();</script>
```
player.addAction( 'api.after', function( element ) {
element.classList.remove( 'is-loading' );
} );
### NPM
player.load();
</script>
```
npm i @thelevicole/youtube-to-html5-loader
```
## Accepted URL patterns
Below is a list of varying YouTube url patterns, which include http/s and www/non-www.
```javascript
const YouTubeToHtml5 = require('@thelevicole/youtube-to-html5-loader');
new YouTubeToHtml5();
```
### jsDelivr CDN
```html
<script src="https://cdn.jsdelivr.net/npm/@thelevicole/youtube-to-html5-loader@3.0.0/dist/YouTubeToHtml5.min.js"></script>
```
## Options example
```html
<video class="youtube-video" data-yt="https://youtube.com/watch?v=ScMzIvxBSi4"></video>
<script src="YouTubeToHtml5.js"></script>
<script>
new YouTubeToHtml5({
selector: '.youtube-video',
attribute: 'data-yt'
});
</script>
```
## Internal API example
```html
<video data-yt2html5="YOUTUBE_URL_OR_ID_GOES_HERE"></video>
<script src="YouTubeToHtml5.js"></script>
<script>
var player = new YouTubeToHtml5({
autoload: false // Disable loading videos on init, `.load()` method is required.
});
// Add loading class to video element
player.addAction('api.before', function(element) {
element.classList.add('is-loading');
});
// Remove loading class after API HTTP request completes.
player.addAction('api.after', function(element) {
element.classList.remove('is-loading');
});
// Now we can load videos.
player.load();
</script>
```
## Accepted URL patterns
Below is a list of varying YouTube url patterns, which include http/s and www/non-www.
```
youtube.com/watch?v=ScMzIvxBSi4

@@ -67,2 +91,1 @@ youtube.com/watch?vi=ScMzIvxBSi4

```

@@ -32,3 +32,3 @@ 'use strict';

attribute: 'data-yt2html5',
formats: [ '1080p', '720p', '360p' ],
formats: '*', // Accepts an array of formats e.g. [ '1080p', '720p', '320p' ] or a single format '1080p'. Asterix for all.
autoload: true

@@ -126,2 +126,3 @@ };

*
* @link {https://support.google.com/youtube/answer/2853702}
* @type {object}

@@ -134,6 +135,6 @@ */

38: '3072p',
82: '360p3d',
83: '480p3d',
84: '720p3d',
85: '1080p3d',
82: '360p3d', // 3D
83: '480p3d', // 3D
84: '720p3d', // 3D
85: '1080p3d', // 3D
133: '240pna',

@@ -145,8 +146,8 @@ 134: '360pna',

264: '1440pna',
298: '720p60',
299: '1080p60na',
160: '144pna',
139: '48kbps',
140: '128kbps',
141: '256kbps'
298: '720p60', // 60fps
299: '1080p60na', // 60fps
160: '144pna', // Audio
139: '48kbps', // Audio
140: '128kbps', // Audio
141: '256kbps' // Audio
};

@@ -193,2 +194,21 @@

/**
* Get the users defined allowed formats. Defaults to all.
*
* @return {string[]}
*/
YouTubeToHtml5.prototype.getAllowedFormats = function() {
let allowedFormats = [];
if ( Array.isArray( this.options.formats ) ) {
allowedFormats = this.options.formats;
} else if ( this.itagMap[ this.options.formats ] ) {
allowedFormats = [ this.options.formats ];
} else if ( this.options.formats === '*' ) {
allowedFormats = Object.values( this.itagMap ).sort();
}
return allowedFormats;
};
/**
* Get list of elements found with the selector.

@@ -227,3 +247,3 @@ *

return this.applyFilters( 'youtube.api.endpoint', url, videoId, hostId );
return this.applyFilters( 'api.endpoint', url, videoId, hostId );
};

@@ -258,14 +278,18 @@

let streams = [];
let result = {};
let results = {};
let data = this.parseUriString( rawData );
data.player_response = JSON.parse( data.player_response );
data.fflags = this.parseUriString( data.fflags );
let response = this.parseUriString( rawData );
response.player_response = JSON.parse( response.player_response );
response.fflags = this.parseUriString( response.fflags );
// Internal API filter
data = this.applyFilters( 'youtube.meta', data, rawData );
/**
* Filter parsed API response.
*
* @type {object}
*/
response = this.applyFilters( 'api.response', response, rawData );
// Get data from API
if ( data.hasOwnProperty( 'url_encoded_fmt_stream_map' ) ) {
streams = streams.concat( data.url_encoded_fmt_stream_map.split( ',' ).map( s => {
// Extract data from API, in order of priority
if ( response.hasOwnProperty( 'url_encoded_fmt_stream_map' ) ) {
streams = streams.concat( response.url_encoded_fmt_stream_map.split( ',' ).map( s => {
return this.parseUriString( s );

@@ -275,8 +299,4 @@ } ) );

else if ( data.player_response.streamingData && data.player_response.streamingData.formats ) {
streams = streams.concat( data.player_response.streamingData.formats );
}
else if ( data.hasOwnProperty( 'adaptive_fmts' ) ) {
streams = streams.concat( data.adaptive_fmts.split( ',' ).map( s => {
else if ( response.hasOwnProperty( 'adaptive_fmts' ) ) {
streams = streams.concat( response.adaptive_fmts.split( ',' ).map( s => {
return this.parseUriString( s );

@@ -286,14 +306,50 @@ } ) );

else if ( data.player_response.streamingData && data.player_response.streamingData.adaptiveFormats ) {
streams = streams.concat( data.player_response.streamingData.adaptiveFormats );
else if ( response.player_response.streamingData && response.player_response.streamingData.adaptiveFormats ) {
streams = streams.concat( response.player_response.streamingData.adaptiveFormats );
}
else if ( response.player_response.streamingData && response.player_response.streamingData.formats ) {
streams = streams.concat( response.player_response.streamingData.formats );
}
// Build results array
streams.forEach( stream => {
if ( this.itagMap[ stream.itag ] && stream.url ) {
result[ this.itagMap[ stream.itag ] ] = stream.url;
let streamType = 'unknown';
let streamMime = 'unknown';
// Extract stream data from mimetype.
if ( 'mimeType' in stream ) {
const mimeParts = stream.mimeType.match( /^(audio|video)(?:\/([^;]+);)?/i );
if ( mimeParts[ 1 ] ) {
streamType = mimeParts[ 1 ];
}
if ( mimeParts[ 2 ] ) {
streamMime = mimeParts[ 2 ];
}
}
results[ this.itagMap[ stream.itag ] ] = {
url: stream.url,
label: stream.qualityLabel || this.itagMap[ stream.itag ],
type: streamType,
mime: streamMime
};
}
} );
return result;
/**
* Apply filter filter.
*
* @param {object} results Object containing extracted results from API response.
* @param {object} response Parsed API response.
*
* @type {object}
*/
results = this.applyFilters( 'api.results', results, response );
return results;
};

@@ -321,11 +377,40 @@

YouTubeToHtml5.prototype.loadSingle = function( element, attr = null ) {
/**
* Attribute name for grabbing YouTube identifier/url.
*
* @type {string}
*/
const attribute = attr || this.options.attribute;
// Check if element has attribute value
if ( element.getAttribute( attribute ) ) {
/**
* Attempt extraction of YouTube video ID. Returns attribute value if no match.
*
* @type {string}
*/
const videoId = this.urlToId( element.getAttribute( attribute ) );
/**
* Build the request URL from YouTube ID.
*
* @type {string}
*/
const requestUrl = this.youtubeDataApiEndpoint( videoId );
/**
* Call action before request is made.
*
* @param {HTMLElement} element
*/
this.doAction( 'api.before', element );
/**
* Make the HTTP request.
*/
this.fetch( requestUrl ).then( response => {
if ( response ) {

@@ -335,8 +420,14 @@

if ( streams && this.options.formats ) {
if ( streams ) {
for ( let i = 0; i < this.options.formats.length; i++ ) {
const format = this.options.formats[ i ];
const allowedFormats = this.getAllowedFormats();
// Select the default value.
var selectedStream = null;
var selectedFormat = null;
for ( let i = 0; i < allowedFormats.length; i++ ) {
const format = allowedFormats[ i ];
if ( format in streams ) {
element.src = this.applyFilters( 'video.source', streams[ format ], element, format, streams );
selectedStream = streams[ format ];
selectedFormat = format;
break;

@@ -346,5 +437,35 @@ }

/**
* Fitler selected video stream object.
*
* @param {object} selectedStream Object containing url and label.
* @param {HTMLElement} element Video element.
* @param {string} selectedFormat Select itag value.
* @param {object} streams Object of itag and stream objects.
*
* @type {null|object}
*/
selectedStream = this.applyFilters( 'video.stream', selectedStream, element, selectedFormat, streams );
/**
* Fitler the selected video source string.
*
* @param {string} selectedSource Source stream url.
* @param {object} selectedStream Stream object.
* @param {HTMLElement} element Video element.
* @param {string} selectedFormat Select itag value.
* @param {object} streams Object of itag and stream objects.
*
* @type {null|string}
*/
element.src = this.applyFilters( 'video.source', selectedStream.url, selectedStream, element, selectedFormat, streams );
}
}
} ).finally( response => {
/**
* Allways call action after request completion.
*
* @param {HTMLElement} element
* @param {object} response
*/
this.doAction( 'api.after', element, response );

@@ -351,0 +472,0 @@ } );

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc