mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-24 15:21:54 +00:00
refactor!: cleanup platform support (#306)
* refactor!: cleanup platform support * chore: lint * fix: web platform * feat: provide UniversalCache Provide UniversalCache as a wrapper around Platform.shim.Cache. * fix: invalid import * refactor: remove isolated-vm support * fix: type info * refactor: cleanup exports * fix: mark jintr as external dependency In the bundled CJS node build, mark jintr as external. * chore: add additional exports web exports provide a way to select web implementation manually without relying on the bundler to select it correctly from the "exports" field web points to src/platform/web.js web.bundle points to bundle/browser.js web.bundle.browser points to bundle/browser.min.js agnostic exports provide users of the library to provide their own platform implementation without first importing the default one. agnostic points to src/platform/lib.ts * fix: toDash on web * revert: eval is synchronous * fix: use serializeDOM in FormatUtils * ci: automate releases with `release-please` * chore: clean up workflow files * ci: fix NPM publish action --------- Co-authored-by: LuanRT <luan.lrt4@gmail.com>
This commit is contained in:
54
src/platform/README.md
Normal file
54
src/platform/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Platform Support
|
||||
|
||||
YouTube.js is designed to be as platform agnostic as possible. To achieve this, we require all platforms wishing to use YouTube.js to provide a few shims around the platform's native APIs.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
We provide shims for the following platforms:
|
||||
|
||||
- Modern Browsers
|
||||
- Node.js
|
||||
- Deno
|
||||
|
||||
## Contributing Support for a New Platform
|
||||
|
||||
If you wish to bring YouTube.js to another platform, you will need to provide the following shims as specified by the `PlatformShim` type:
|
||||
|
||||
- `runtime`: String name of the platform.
|
||||
- `info`: Object containing the package information read from `package.json`.
|
||||
- `version`: The version of the package.
|
||||
- `bugs_url`: The URL to the package's bug tracker.
|
||||
- `repository_url`: The URL to the package's repository.
|
||||
- `server`: Boolean indicating whether the platform is a server or not. Used for setting some additional headers not possible on a web browser.
|
||||
- `Cache`: Class that implements the `ICache` interface using the platform's native APIs.
|
||||
- `sha1hash(data: string)`: Function that takes a string and returns a SHA-1 hash of it.
|
||||
- `uuidv4()`: Function that returns a UUIDv4 string.
|
||||
- `eval(code: string, env: Record<string, VMPrimative>)`: Function to evaluate untrusted javascript script and return the result.
|
||||
- `DOMParser`: DOMParser implementation. Used for generating DASH manifests.
|
||||
- `fetch`: WHATWG Fetch API implementation.
|
||||
- `Headers`: Headers implementation.
|
||||
- `Request`: Request implementation.
|
||||
- `Response`: Response implementation.
|
||||
- `FormData`: FormData implementation.
|
||||
- `File`: File implementation.
|
||||
- `ReadableStream`: ReadableStream implementation.
|
||||
|
||||
An entry point for the platform should be added in the `src/platform` directory and should be formatted as follows:
|
||||
|
||||
```ts
|
||||
import { Platform } from '../utils/Utils.js';
|
||||
import { PlatformShim } from "../types";
|
||||
import { ICache } from '../types/Cache.js';
|
||||
|
||||
class Cache implements ICache {
|
||||
// ...
|
||||
}
|
||||
|
||||
Platform.load({
|
||||
// ... shims
|
||||
});
|
||||
|
||||
export * from './lib.js';
|
||||
import Innertube from './lib.js';
|
||||
export default Innertube;
|
||||
```
|
||||
113
src/platform/deno.ts
Normal file
113
src/platform/deno.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Deno Platform Support
|
||||
import type { ICache } from '../types/Cache.js';
|
||||
import { Platform } from '../utils/Utils.js';
|
||||
import DOMParser from './polyfills/server-dom.js';
|
||||
import evaluate from './jsruntime/jinter.js';
|
||||
import sha1Hash from './polyfills/web-crypto.js';
|
||||
import package_json from '../../package.json' assert { type: 'json' };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const Deno = globalThis.Deno as any;
|
||||
|
||||
class Cache implements ICache {
|
||||
#persistent_directory: string;
|
||||
#persistent: boolean;
|
||||
|
||||
constructor(persistent = false, persistent_directory?: string) {
|
||||
this.#persistent_directory = persistent_directory || Cache.default_persistent_directory;
|
||||
this.#persistent = persistent;
|
||||
}
|
||||
|
||||
static get temp_directory() {
|
||||
return `${Deno.env.get('TMPDIR') || Deno.env.get('TMP') || Deno.env.get('TEMP') || '/tmp'}/youtubei.js`;
|
||||
}
|
||||
|
||||
static get default_persistent_directory() {
|
||||
return `${Deno.cwd()}/.cache/youtubei.js`;
|
||||
}
|
||||
|
||||
get cache_dir() {
|
||||
return this.#persistent ? this.#persistent_directory : Cache.temp_directory;
|
||||
}
|
||||
|
||||
async #createCache() {
|
||||
const dir = this.cache_dir;
|
||||
try {
|
||||
const cwd = await Deno.stat(dir);
|
||||
if (!cwd.isDirectory)
|
||||
throw new Error('An unexpected file was found in place of the cache directory');
|
||||
} catch (e) {
|
||||
if (e instanceof Deno.errors.NotFound)
|
||||
await Deno.mkdir(dir, { recursive: true });
|
||||
else
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
await this.#createCache();
|
||||
const file = `${this.cache_dir}/${key}`;
|
||||
try {
|
||||
const stat = await Deno.stat(file);
|
||||
if (stat.isFile) {
|
||||
const data: Uint8Array = await Deno.readFile(file);
|
||||
return data.buffer;
|
||||
}
|
||||
throw new Error('An unexpected file was found in place of the cache key');
|
||||
|
||||
} catch (e) {
|
||||
if (e instanceof Deno.errors.NotFound)
|
||||
return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: ArrayBuffer) {
|
||||
await this.#createCache();
|
||||
const file = `${this.cache_dir}/${key}`;
|
||||
await Deno.writeFile(file, new Uint8Array(value));
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
await this.#createCache();
|
||||
const file = `${this.cache_dir}/${key}`;
|
||||
try {
|
||||
await Deno.remove(file);
|
||||
} catch (e) {
|
||||
if (e instanceof Deno.errors.NotFound) return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Platform.load({
|
||||
runtime: 'deno',
|
||||
info: {
|
||||
version: package_json.version,
|
||||
bugs_url: package_json.bugs.url,
|
||||
repo_url: package_json.homepage.split('#')[0]
|
||||
},
|
||||
server: true,
|
||||
Cache: Cache,
|
||||
sha1Hash,
|
||||
uuidv4() {
|
||||
return crypto.randomUUID();
|
||||
},
|
||||
eval: evaluate,
|
||||
DOMParser,
|
||||
serializeDOM(document) {
|
||||
return document.toString();
|
||||
},
|
||||
fetch: globalThis.fetch,
|
||||
Request: globalThis.Request,
|
||||
Response: globalThis.Response,
|
||||
Headers: globalThis.Headers,
|
||||
FormData: globalThis.FormData,
|
||||
File: globalThis.File,
|
||||
ReadableStream: globalThis.ReadableStream
|
||||
});
|
||||
|
||||
export * from './lib.js';
|
||||
import Innertube from './lib.js';
|
||||
export default Innertube;
|
||||
10
src/platform/jsruntime/jinter.ts
Normal file
10
src/platform/jsruntime/jinter.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import Jinter from 'jintr';
|
||||
import { VMPrimative } from '../../types/PlatformShim.js';
|
||||
|
||||
export default function evaluate(code: string, env: Record<string, VMPrimative>) {
|
||||
const runtime = new Jinter.default(code);
|
||||
for (const [ key, value ] of Object.entries(env)) {
|
||||
runtime.scope.set(key, value);
|
||||
}
|
||||
return runtime.interpret();
|
||||
}
|
||||
12
src/platform/lib.ts
Normal file
12
src/platform/lib.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import Innertube from '../Innertube.js';
|
||||
|
||||
export * from '../core/index.js';
|
||||
export * from '../parser/index.js';
|
||||
export { default as Parser } from '../parser/index.js';
|
||||
export { default as Proto } from '../proto/index.js';
|
||||
export * as Types from '../types/index.js';
|
||||
export * from '../utils/index.js';
|
||||
|
||||
export { default as Innertube } from '../Innertube.js';
|
||||
|
||||
export default Innertube;
|
||||
131
src/platform/node.ts
Normal file
131
src/platform/node.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Node.js Platform Support
|
||||
import { ReadableStream } from 'stream/web';
|
||||
import {
|
||||
fetch as defaultFetch,
|
||||
Request,
|
||||
Response,
|
||||
Headers,
|
||||
FormData,
|
||||
File
|
||||
} from 'undici';
|
||||
import { ICache } from '../types/Cache.js';
|
||||
import { Platform } from '../utils/Utils.js';
|
||||
import crypto from 'crypto';
|
||||
import { FetchFunction } from '../types/PlatformShim.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import { readFileSync } from 'fs';
|
||||
import DOMParser from './polyfills/server-dom.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import evaluate from './jsruntime/jinter.js';
|
||||
|
||||
const meta_url = import.meta.url;
|
||||
const is_cjs = !meta_url;
|
||||
const __dirname__ = is_cjs ? __dirname : path.dirname(fileURLToPath(meta_url));
|
||||
|
||||
const package_json = JSON.parse(readFileSync(path.resolve(__dirname__, is_cjs ? '../package.json' : '../../package.json'), 'utf-8'));
|
||||
|
||||
class Cache implements ICache {
|
||||
#persistent_directory: string;
|
||||
#persistent: boolean;
|
||||
|
||||
constructor(persistent = false, persistent_directory?: string) {
|
||||
this.#persistent_directory = persistent_directory || Cache.default_persistent_directory;
|
||||
this.#persistent = persistent;
|
||||
}
|
||||
|
||||
static get temp_directory() {
|
||||
return `${os.tmpdir()}/youtubei.js`;
|
||||
}
|
||||
|
||||
static get default_persistent_directory() {
|
||||
return path.resolve(__dirname__, '..', '..', '.cache', 'youtubei.js');
|
||||
}
|
||||
|
||||
get cache_dir() {
|
||||
return this.#persistent ? this.#persistent_directory : Cache.temp_directory;
|
||||
}
|
||||
|
||||
async #createCache() {
|
||||
const dir = this.cache_dir;
|
||||
try {
|
||||
const cwd = await fs.stat(dir);
|
||||
if (!cwd.isDirectory())
|
||||
throw new Error('An unexpected file was found in place of the cache directory');
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'ENOENT')
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
else
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
await this.#createCache();
|
||||
const file = path.resolve(this.cache_dir, key);
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
if (stat.isFile()) {
|
||||
const data: Buffer = await fs.readFile(file);
|
||||
return data.buffer;
|
||||
}
|
||||
throw new Error('An unexpected file was found in place of the cache key');
|
||||
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'ENOENT')
|
||||
return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: ArrayBuffer) {
|
||||
await this.#createCache();
|
||||
const file = path.resolve(this.cache_dir, key);
|
||||
await fs.writeFile(file, new Uint8Array(value));
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
await this.#createCache();
|
||||
const file = path.resolve(this.cache_dir, key);
|
||||
try {
|
||||
await fs.unlink(file);
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'ENOENT') return;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Platform.load({
|
||||
runtime: 'node',
|
||||
info: {
|
||||
version: package_json.version,
|
||||
bugs_url: package_json.bugs.url,
|
||||
repo_url: package_json.homepage.split('#')[0]
|
||||
},
|
||||
server: true,
|
||||
Cache: Cache,
|
||||
sha1Hash: async (data: string) => {
|
||||
return crypto.createHash('sha1').update(data).digest('hex');
|
||||
},
|
||||
uuidv4() {
|
||||
return crypto.randomUUID();
|
||||
},
|
||||
serializeDOM(document) {
|
||||
return document.toString();
|
||||
},
|
||||
eval: evaluate,
|
||||
DOMParser,
|
||||
fetch: defaultFetch as unknown as FetchFunction,
|
||||
Request: Request as unknown as typeof globalThis.Request,
|
||||
Response: Response as unknown as typeof globalThis.Response,
|
||||
Headers: Headers as unknown as typeof globalThis.Headers,
|
||||
FormData: FormData as unknown as typeof globalThis.FormData,
|
||||
File: File as unknown as typeof globalThis.File,
|
||||
ReadableStream: ReadableStream as unknown as typeof globalThis.ReadableStream
|
||||
});
|
||||
|
||||
export * from './lib.js';
|
||||
import Innertube from './lib.js';
|
||||
export default Innertube;
|
||||
3
src/platform/polyfills/server-dom.ts
Normal file
3
src/platform/polyfills/server-dom.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { DOMParser as DOMParserImpl } from 'linkedom';
|
||||
|
||||
export default DOMParserImpl as typeof globalThis.DOMParser;
|
||||
30
src/platform/polyfills/web-crypto.ts
Normal file
30
src/platform/polyfills/web-crypto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export default async function sha1Hash(str: string) {
|
||||
const byteToHex = [
|
||||
'00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0a', '0b', '0c', '0d', '0e', '0f',
|
||||
'10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '1a', '1b', '1c', '1d', '1e', '1f',
|
||||
'20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '2a', '2b', '2c', '2d', '2e', '2f',
|
||||
'30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3a', '3b', '3c', '3d', '3e', '3f',
|
||||
'40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '4a', '4b', '4c', '4d', '4e', '4f',
|
||||
'50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '5a', '5b', '5c', '5d', '5e', '5f',
|
||||
'60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '6a', '6b', '6c', '6d', '6e', '6f',
|
||||
'70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '7a', '7b', '7c', '7d', '7e', '7f',
|
||||
'80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '8a', '8b', '8c', '8d', '8e', '8f',
|
||||
'90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '9a', '9b', '9c', '9d', '9e', '9f',
|
||||
'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'aa', 'ab', 'ac', 'ad', 'ae', 'af',
|
||||
'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7', 'b8', 'b9', 'ba', 'bb', 'bc', 'bd', 'be', 'bf',
|
||||
'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'ca', 'cb', 'cc', 'cd', 'ce', 'cf',
|
||||
'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7', 'd8', 'd9', 'da', 'db', 'dc', 'dd', 'de', 'df',
|
||||
'e0', 'e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', 'e8', 'e9', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef',
|
||||
'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff'
|
||||
];
|
||||
|
||||
function hex(arrayBuffer: ArrayBuffer): string {
|
||||
const buff = new Uint8Array(arrayBuffer);
|
||||
const hexOctets = [];
|
||||
for (let i = 0; i < buff.length; ++i)
|
||||
hexOctets.push(byteToHex[buff[i]]);
|
||||
return hexOctets.join('');
|
||||
}
|
||||
|
||||
return hex(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(str)));
|
||||
}
|
||||
123
src/platform/web.ts
Normal file
123
src/platform/web.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Deno Platform Support
|
||||
import { ICache } from '../types/Cache.js';
|
||||
import { Platform } from '../utils/Utils.js';
|
||||
import sha1Hash from './polyfills/web-crypto.js';
|
||||
import package_json from '../../package.json' assert { type: 'json' };
|
||||
import evaluate from './jsruntime/jinter.js';
|
||||
|
||||
class Cache implements ICache {
|
||||
#persistent_directory: string;
|
||||
#persistent: boolean;
|
||||
|
||||
constructor(persistent = false, persistent_directory?: string) {
|
||||
this.#persistent_directory = persistent_directory || '';
|
||||
this.#persistent = persistent;
|
||||
}
|
||||
|
||||
get cache_dir() {
|
||||
return this.#persistent ? this.#persistent_directory : '';
|
||||
}
|
||||
|
||||
#getBrowserDB() {
|
||||
const indexedDB: IDBFactory = Reflect.get(globalThis, 'indexedDB') || Reflect.get(globalThis, 'webkitIndexedDB') || Reflect.get(globalThis, 'mozIndexedDB') || Reflect.get(globalThis, 'msIndexedDB');
|
||||
|
||||
if (!indexedDB) return console.log('IndexedDB is not supported. No cache will be used.');
|
||||
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('youtubei.js', 1);
|
||||
request.onsuccess = function () {
|
||||
resolve(this.result);
|
||||
};
|
||||
|
||||
request.onerror = function (event) {
|
||||
reject('indexedDB request error');
|
||||
console.error(event);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = function () {
|
||||
const store = this.result.createObjectStore('kv-store', {
|
||||
keyPath: 'k'
|
||||
});
|
||||
|
||||
store.transaction.oncomplete = function () {
|
||||
resolve(this.db);
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
const db = await this.#getBrowserDB();
|
||||
if (!db) return;
|
||||
|
||||
return new Promise<ArrayBuffer | undefined>((resolve, reject) => {
|
||||
const request = db.transaction('kv-store', 'readonly').objectStore('kv-store').get(key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = function () {
|
||||
const result: Uint8Array | undefined = this.result?.v;
|
||||
resolve(result ? result.buffer : undefined);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async set(key: string, value: ArrayBuffer) {
|
||||
const db = await this.#getBrowserDB();
|
||||
if (!db) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = db.transaction('kv-store', 'readwrite').objectStore('kv-store').put({ k: key, v: value });
|
||||
request.onerror = reject;
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
const db = await this.#getBrowserDB();
|
||||
if (!db) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = db.transaction('kv-store', 'readwrite').objectStore('kv-store').delete(key);
|
||||
request.onerror = reject;
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Platform.load({
|
||||
runtime: 'browser',
|
||||
server: false,
|
||||
info: {
|
||||
version: package_json.version,
|
||||
bugs_url: package_json.bugs.url,
|
||||
repo_url: package_json.homepage.split('#')[0]
|
||||
},
|
||||
Cache: Cache,
|
||||
sha1Hash,
|
||||
uuidv4() {
|
||||
if (globalThis.crypto?.randomUUID()) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
// See https://stackoverflow.com/a/2117523
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (cc) => {
|
||||
const c = parseInt(cc);
|
||||
return (c ^ window.crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16);
|
||||
});
|
||||
},
|
||||
eval: evaluate,
|
||||
DOMParser: globalThis.DOMParser,
|
||||
serializeDOM(document) {
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
},
|
||||
fetch: globalThis.fetch,
|
||||
Request: globalThis.Request,
|
||||
Response: globalThis.Response,
|
||||
Headers: globalThis.Headers,
|
||||
FormData: globalThis.FormData,
|
||||
File: globalThis.File,
|
||||
ReadableStream: globalThis.ReadableStream
|
||||
});
|
||||
|
||||
export * from './lib.js';
|
||||
import Innertube from './lib.js';
|
||||
export default Innertube;
|
||||
Reference in New Issue
Block a user