bunnyhls 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/dist/client/index.d.mts +12 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.js +107 -0
- package/dist/client/index.mjs +70 -0
- package/dist/server/index.d.mts +9 -0
- package/dist/server/index.d.ts +9 -0
- package/dist/server/index.js +182 -0
- package/dist/server/index.mjs +145 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# bunnyHLS
|
|
2
|
+
|
|
3
|
+
A full-stack npm package for BunnyNet HLS video playback with React components and Next.js API handlers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install bunnyhls
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- 🎥 React component for HLS video playback with auto-token refresh
|
|
14
|
+
- 🔐 Server-side URL signing handler for Next.js
|
|
15
|
+
- 🔄 Automatic token refresh on 403 errors
|
|
16
|
+
- 📦 TypeScript support
|
|
17
|
+
- ⚡ Works with both Next.js App Router and Pages Router
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Step 1: Create the API Route
|
|
22
|
+
|
|
23
|
+
#### Next.js Pages Router (`/app/api/video-token.js`)
|
|
24
|
+
|
|
25
|
+
```javascript
|
|
26
|
+
import { createBunnyHandler } from 'bunnyhls/server';
|
|
27
|
+
|
|
28
|
+
export default createBunnyHandler(process.env.BUNNY_SECURITY_KEY);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
#### Next.js App Router (`/app/api/video-token/route.ts`)
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { createBunnyHandler } from 'bunnyhls/server';
|
|
35
|
+
import { NextRequest } from 'next/server';
|
|
36
|
+
|
|
37
|
+
const handler = createBunnyHandler({
|
|
38
|
+
securityKey: process.env.BUNNY_SECURITY_KEY!,
|
|
39
|
+
libraryHost: process.env.LIBRARY_HOST, //eg. hvz-c561dc91-370.b-cdn.net
|
|
40
|
+
expirationTime: 3600, // optional, default: 3600
|
|
41
|
+
isDirectory: true // optional, default: true
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export async function GET(request: NextRequest) {
|
|
45
|
+
return handler(request);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Step 2: Use the Component
|
|
50
|
+
|
|
51
|
+
#### Next.js App Router (`/app/page.tsx`)
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { BunnyVideoPlayer } from 'bunnyhls/client';
|
|
55
|
+
|
|
56
|
+
export default function Page() {
|
|
57
|
+
return (
|
|
58
|
+
<BunnyVideoPlayer
|
|
59
|
+
videoId="7f142e98-c6df-4e3c-8ee4-9b98b6abf73f"
|
|
60
|
+
tokenUrl="/api/video-token"
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
### `BunnyVideoPlayer` Component
|
|
70
|
+
|
|
71
|
+
Props:
|
|
72
|
+
|
|
73
|
+
- `videoId` (string, required): The BunnyNet video ID
|
|
74
|
+
- `tokenUrl` (string, required): The API endpoint that returns signed URLs
|
|
75
|
+
- `libraryHost` (string, required): Your BunnyNet library host
|
|
76
|
+
- `controls` (boolean, optional): Show video controls (default: `true`)
|
|
77
|
+
- `style` (CSSProperties, optional): Custom styles for the video element
|
|
78
|
+
- `className` (string, optional): Custom CSS class for the video element
|
|
79
|
+
|
|
80
|
+
### `createBunnyHandler` Function
|
|
81
|
+
|
|
82
|
+
Creates a handler function for your API route.
|
|
83
|
+
|
|
84
|
+
**Simple usage:**
|
|
85
|
+
```javascript
|
|
86
|
+
createBunnyHandler(securityKey)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Advanced usage:**
|
|
90
|
+
```javascript
|
|
91
|
+
createBunnyHandler({
|
|
92
|
+
securityKey: 'your-security-key',
|
|
93
|
+
libraryHost: 'vz-c561dc91-370.b-cdn.net',
|
|
94
|
+
expirationTime: 3600,
|
|
95
|
+
isDirectory: true
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Options:
|
|
100
|
+
|
|
101
|
+
- `securityKey` (string, required): Your BunnyNet security key
|
|
102
|
+
- `libraryHost` (string, optional): Your BunnyNet library host
|
|
103
|
+
- `expirationTime` (number, optional): Token expiration time in seconds (default: 3600)
|
|
104
|
+
- `isDirectory` (boolean, optional): Whether to sign as directory (default: true)
|
|
105
|
+
|
|
106
|
+
## Environment Variables
|
|
107
|
+
|
|
108
|
+
Make sure to set your BunnyNet security key:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
BUNNY_SECURITY_KEY=your-security-key-here
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## How It Works
|
|
115
|
+
|
|
116
|
+
1. The `BunnyVideoPlayer` component requests a signed URL from your API endpoint
|
|
117
|
+
2. The API handler uses your security key to sign the HLS playlist URL
|
|
118
|
+
3. The component loads the signed URL using hls.js
|
|
119
|
+
4. If a 403 error occurs, the component automatically requests a new token and retries
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
ISC
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface BunnyVideoPlayerProps {
|
|
4
|
+
videoId: string;
|
|
5
|
+
tokenUrl: string;
|
|
6
|
+
controls?: boolean;
|
|
7
|
+
style?: React.CSSProperties;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
declare const BunnyVideoPlayer: React.FC<BunnyVideoPlayerProps>;
|
|
11
|
+
|
|
12
|
+
export { BunnyVideoPlayer, type BunnyVideoPlayerProps };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface BunnyVideoPlayerProps {
|
|
4
|
+
videoId: string;
|
|
5
|
+
tokenUrl: string;
|
|
6
|
+
controls?: boolean;
|
|
7
|
+
style?: React.CSSProperties;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
declare const BunnyVideoPlayer: React.FC<BunnyVideoPlayerProps>;
|
|
11
|
+
|
|
12
|
+
export { BunnyVideoPlayer, type BunnyVideoPlayerProps };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/client/index.ts
|
|
31
|
+
var client_exports = {};
|
|
32
|
+
__export(client_exports, {
|
|
33
|
+
BunnyVideoPlayer: () => BunnyVideoPlayer
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(client_exports);
|
|
36
|
+
|
|
37
|
+
// src/client/BunnyVideoPlayer.tsx
|
|
38
|
+
var import_react = __toESM(require("react"));
|
|
39
|
+
var import_hls = __toESM(require("hls.js"));
|
|
40
|
+
var BunnyVideoPlayer = ({
|
|
41
|
+
videoId,
|
|
42
|
+
tokenUrl,
|
|
43
|
+
controls = true,
|
|
44
|
+
style,
|
|
45
|
+
className
|
|
46
|
+
}) => {
|
|
47
|
+
const videoRef = (0, import_react.useRef)(null);
|
|
48
|
+
const hlsRef = (0, import_react.useRef)(null);
|
|
49
|
+
(0, import_react.useEffect)(() => {
|
|
50
|
+
if (!videoRef.current) return;
|
|
51
|
+
const hls = new import_hls.default();
|
|
52
|
+
hlsRef.current = hls;
|
|
53
|
+
const loadSourceWithToken = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${tokenUrl}?videoId=${videoId}`);
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
throw new Error(`Failed to fetch token: ${res.statusText}`);
|
|
58
|
+
}
|
|
59
|
+
const { signedUrl } = await res.json();
|
|
60
|
+
hls.loadSource(signedUrl);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error("Error loading video token:", error);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
hls.attachMedia(videoRef.current);
|
|
66
|
+
loadSourceWithToken();
|
|
67
|
+
hls.on(import_hls.default.Events.ERROR, async (event, data) => {
|
|
68
|
+
if (data.fatal) {
|
|
69
|
+
switch (data.type) {
|
|
70
|
+
case import_hls.default.ErrorTypes.NETWORK_ERROR:
|
|
71
|
+
if (data.response?.code === 403) {
|
|
72
|
+
await loadSourceWithToken();
|
|
73
|
+
hls.startLoad();
|
|
74
|
+
} else {
|
|
75
|
+
hls.startLoad();
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case import_hls.default.ErrorTypes.MEDIA_ERROR:
|
|
79
|
+
hls.recoverMediaError();
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
hls.destroy();
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return () => {
|
|
88
|
+
if (hlsRef.current) {
|
|
89
|
+
hlsRef.current.destroy();
|
|
90
|
+
hlsRef.current = null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}, [videoId, tokenUrl]);
|
|
94
|
+
return /* @__PURE__ */ import_react.default.createElement(
|
|
95
|
+
"video",
|
|
96
|
+
{
|
|
97
|
+
ref: videoRef,
|
|
98
|
+
controls,
|
|
99
|
+
style: style || { width: "100%" },
|
|
100
|
+
className
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
105
|
+
0 && (module.exports = {
|
|
106
|
+
BunnyVideoPlayer
|
|
107
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/client/BunnyVideoPlayer.tsx
|
|
2
|
+
import React, { useEffect, useRef } from "react";
|
|
3
|
+
import Hls from "hls.js";
|
|
4
|
+
var BunnyVideoPlayer = ({
|
|
5
|
+
videoId,
|
|
6
|
+
tokenUrl,
|
|
7
|
+
controls = true,
|
|
8
|
+
style,
|
|
9
|
+
className
|
|
10
|
+
}) => {
|
|
11
|
+
const videoRef = useRef(null);
|
|
12
|
+
const hlsRef = useRef(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!videoRef.current) return;
|
|
15
|
+
const hls = new Hls();
|
|
16
|
+
hlsRef.current = hls;
|
|
17
|
+
const loadSourceWithToken = async () => {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`${tokenUrl}?videoId=${videoId}`);
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(`Failed to fetch token: ${res.statusText}`);
|
|
22
|
+
}
|
|
23
|
+
const { signedUrl } = await res.json();
|
|
24
|
+
hls.loadSource(signedUrl);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error("Error loading video token:", error);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
hls.attachMedia(videoRef.current);
|
|
30
|
+
loadSourceWithToken();
|
|
31
|
+
hls.on(Hls.Events.ERROR, async (event, data) => {
|
|
32
|
+
if (data.fatal) {
|
|
33
|
+
switch (data.type) {
|
|
34
|
+
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
35
|
+
if (data.response?.code === 403) {
|
|
36
|
+
await loadSourceWithToken();
|
|
37
|
+
hls.startLoad();
|
|
38
|
+
} else {
|
|
39
|
+
hls.startLoad();
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
43
|
+
hls.recoverMediaError();
|
|
44
|
+
break;
|
|
45
|
+
default:
|
|
46
|
+
hls.destroy();
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
if (hlsRef.current) {
|
|
53
|
+
hlsRef.current.destroy();
|
|
54
|
+
hlsRef.current = null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}, [videoId, tokenUrl]);
|
|
58
|
+
return /* @__PURE__ */ React.createElement(
|
|
59
|
+
"video",
|
|
60
|
+
{
|
|
61
|
+
ref: videoRef,
|
|
62
|
+
controls,
|
|
63
|
+
style: style || { width: "100%" },
|
|
64
|
+
className
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
export {
|
|
69
|
+
BunnyVideoPlayer
|
|
70
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface BunnyHandlerOptions {
|
|
2
|
+
securityKey: string;
|
|
3
|
+
libraryHost: string;
|
|
4
|
+
expirationTime?: number;
|
|
5
|
+
isDirectory?: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare const createBunnyHandler: (options: BunnyHandlerOptions) => (req: any, res?: any) => Promise<any>;
|
|
8
|
+
|
|
9
|
+
export { type BunnyHandlerOptions, createBunnyHandler };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface BunnyHandlerOptions {
|
|
2
|
+
securityKey: string;
|
|
3
|
+
libraryHost: string;
|
|
4
|
+
expirationTime?: number;
|
|
5
|
+
isDirectory?: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare const createBunnyHandler: (options: BunnyHandlerOptions) => (req: any, res?: any) => Promise<any>;
|
|
8
|
+
|
|
9
|
+
export { type BunnyHandlerOptions, createBunnyHandler };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/server/index.ts
|
|
31
|
+
var server_exports = {};
|
|
32
|
+
__export(server_exports, {
|
|
33
|
+
createBunnyHandler: () => createBunnyHandler
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(server_exports);
|
|
36
|
+
|
|
37
|
+
// src/server/signUtils.ts
|
|
38
|
+
var crypto = __toESM(require("crypto"));
|
|
39
|
+
var querystring = __toESM(require("querystring"));
|
|
40
|
+
function addCountries(url, countriesAllowed, countriesBlocked) {
|
|
41
|
+
let tempUrl = url;
|
|
42
|
+
if (countriesAllowed != null) {
|
|
43
|
+
const tempUrlOne = new URL(tempUrl);
|
|
44
|
+
tempUrl += (tempUrlOne.search === "" ? "?" : "&") + "token_countries=" + countriesAllowed;
|
|
45
|
+
}
|
|
46
|
+
if (countriesBlocked != null) {
|
|
47
|
+
const tempUrlTwo = new URL(tempUrl);
|
|
48
|
+
tempUrl += (tempUrlTwo.search === "" ? "?" : "&") + "token_countries_blocked=" + countriesBlocked;
|
|
49
|
+
}
|
|
50
|
+
return tempUrl;
|
|
51
|
+
}
|
|
52
|
+
function signUrl(url, securityKey, expirationTime = 3600, userIp = null, isDirectory = false, pathAllowed = "", countriesAllowed = null, countriesBlocked = null) {
|
|
53
|
+
console.log("[BunnyHLS signUrl] Starting URL signing process");
|
|
54
|
+
console.log("[BunnyHLS signUrl] Input URL:", url);
|
|
55
|
+
console.log("[BunnyHLS signUrl] Parameters:", {
|
|
56
|
+
expirationTime,
|
|
57
|
+
userIp: userIp || "(none)",
|
|
58
|
+
isDirectory,
|
|
59
|
+
pathAllowed: pathAllowed || "(none)",
|
|
60
|
+
countriesAllowed: countriesAllowed || "(none)",
|
|
61
|
+
countriesBlocked: countriesBlocked || "(none)"
|
|
62
|
+
});
|
|
63
|
+
let parameterData = "";
|
|
64
|
+
let parameterDataUrl = "";
|
|
65
|
+
let signaturePath = "";
|
|
66
|
+
let hashableBase = "";
|
|
67
|
+
let token = "";
|
|
68
|
+
const expires = Math.floor((/* @__PURE__ */ new Date()).getTime() / 1e3) + expirationTime;
|
|
69
|
+
console.log("[BunnyHLS signUrl] Expiration timestamp:", expires, `(${expirationTime}s from now)`);
|
|
70
|
+
const urlWithCountries = addCountries(url, countriesAllowed, countriesBlocked);
|
|
71
|
+
const parsedUrl = new URL(urlWithCountries);
|
|
72
|
+
const parameters = new URL(urlWithCountries).searchParams;
|
|
73
|
+
if (pathAllowed !== "") {
|
|
74
|
+
signaturePath = pathAllowed;
|
|
75
|
+
parameters.set("token_path", signaturePath);
|
|
76
|
+
console.log("[BunnyHLS signUrl] Using pathAllowed:", signaturePath);
|
|
77
|
+
} else {
|
|
78
|
+
signaturePath = decodeURIComponent(parsedUrl.pathname);
|
|
79
|
+
console.log("[BunnyHLS signUrl] Using parsed pathname:", signaturePath);
|
|
80
|
+
}
|
|
81
|
+
const sortedParams = Array.from(parameters.entries()).sort();
|
|
82
|
+
console.log("[BunnyHLS signUrl] Sorted parameters:", sortedParams);
|
|
83
|
+
sortedParams.forEach(([key, value]) => {
|
|
84
|
+
if (value === "") {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (parameterData.length > 0) {
|
|
88
|
+
parameterData += "&";
|
|
89
|
+
}
|
|
90
|
+
parameterData += key + "=" + value;
|
|
91
|
+
parameterDataUrl += "&" + key + "=" + querystring.escape(value);
|
|
92
|
+
});
|
|
93
|
+
console.log("[BunnyHLS signUrl] Parameter data:", parameterData);
|
|
94
|
+
console.log("[BunnyHLS signUrl] Parameter data URL:", parameterDataUrl);
|
|
95
|
+
hashableBase = securityKey + signaturePath + expires + (userIp != null ? userIp : "") + parameterData;
|
|
96
|
+
console.log("[BunnyHLS signUrl] Hashable base length:", hashableBase.length);
|
|
97
|
+
console.log("[BunnyHLS signUrl] Hashable base preview:", hashableBase.substring(0, 50) + "...");
|
|
98
|
+
token = Buffer.from(crypto.createHash("sha256").update(hashableBase).digest()).toString("base64");
|
|
99
|
+
token = token.replace(/\n/g, "").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
100
|
+
console.log("[BunnyHLS signUrl] Generated token:", token.substring(0, 20) + "...");
|
|
101
|
+
let finalUrl;
|
|
102
|
+
if (isDirectory) {
|
|
103
|
+
finalUrl = parsedUrl.protocol + "//" + parsedUrl.host + "/bcdn_token=" + token + parameterDataUrl + "&expires=" + expires + parsedUrl.pathname;
|
|
104
|
+
console.log("[BunnyHLS signUrl] Directory mode - Final URL generated");
|
|
105
|
+
} else {
|
|
106
|
+
finalUrl = parsedUrl.protocol + "//" + parsedUrl.host + parsedUrl.pathname + "?token=" + token + parameterDataUrl + "&expires=" + expires;
|
|
107
|
+
console.log("[BunnyHLS signUrl] File mode - Final URL generated");
|
|
108
|
+
}
|
|
109
|
+
console.log("[BunnyHLS signUrl] Signing complete");
|
|
110
|
+
return finalUrl;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/server/index.ts
|
|
114
|
+
var createBunnyHandler = (options) => {
|
|
115
|
+
const {
|
|
116
|
+
securityKey,
|
|
117
|
+
libraryHost,
|
|
118
|
+
expirationTime = 3600,
|
|
119
|
+
isDirectory = true
|
|
120
|
+
} = options;
|
|
121
|
+
return async (req, res) => {
|
|
122
|
+
console.log("[BunnyHLS] Handler called at", (/* @__PURE__ */ new Date()).toISOString());
|
|
123
|
+
let videoId;
|
|
124
|
+
if (req.nextUrl) {
|
|
125
|
+
videoId = req.nextUrl.searchParams.get("videoId") || "";
|
|
126
|
+
console.log("[BunnyHLS] App Router - Request URL:", req.nextUrl.toString());
|
|
127
|
+
} else if (req.query) {
|
|
128
|
+
videoId = req.query.videoId || "";
|
|
129
|
+
console.log("[BunnyHLS] Pages Router - Query params:", req.query);
|
|
130
|
+
} else {
|
|
131
|
+
videoId = "";
|
|
132
|
+
console.log("[BunnyHLS] No videoId found in request");
|
|
133
|
+
}
|
|
134
|
+
console.log("[BunnyHLS] Extracted videoId:", videoId || "(empty)");
|
|
135
|
+
if (!videoId) {
|
|
136
|
+
console.error("[BunnyHLS] ERROR: videoId is required");
|
|
137
|
+
const errorResponse = { error: "videoId is required" };
|
|
138
|
+
if (res) {
|
|
139
|
+
return res.status(400).json(errorResponse);
|
|
140
|
+
}
|
|
141
|
+
return new Response(JSON.stringify(errorResponse), {
|
|
142
|
+
status: 400,
|
|
143
|
+
headers: { "Content-Type": "application/json" }
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const pathAllowed = `/${videoId}/`;
|
|
147
|
+
const url = `https://${libraryHost}/${videoId}/playlist.m3u8`;
|
|
148
|
+
console.log("[BunnyHLS] Signing URL:", url);
|
|
149
|
+
console.log("[BunnyHLS] Config:", {
|
|
150
|
+
libraryHost,
|
|
151
|
+
expirationTime,
|
|
152
|
+
isDirectory,
|
|
153
|
+
pathAllowed,
|
|
154
|
+
securityKeyLength: securityKey?.length || 0
|
|
155
|
+
});
|
|
156
|
+
const signedUrl = signUrl(
|
|
157
|
+
url,
|
|
158
|
+
securityKey,
|
|
159
|
+
expirationTime,
|
|
160
|
+
null,
|
|
161
|
+
isDirectory,
|
|
162
|
+
pathAllowed,
|
|
163
|
+
null,
|
|
164
|
+
null
|
|
165
|
+
);
|
|
166
|
+
console.log("[BunnyHLS] Signed URL generated:", signedUrl);
|
|
167
|
+
console.log("[BunnyHLS] Response prepared successfully");
|
|
168
|
+
const response = { signedUrl };
|
|
169
|
+
if (res) {
|
|
170
|
+
return res.status(200).json(response);
|
|
171
|
+
} else {
|
|
172
|
+
return new Response(JSON.stringify(response), {
|
|
173
|
+
status: 200,
|
|
174
|
+
headers: { "Content-Type": "application/json" }
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
180
|
+
0 && (module.exports = {
|
|
181
|
+
createBunnyHandler
|
|
182
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/server/signUtils.ts
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import * as querystring from "querystring";
|
|
4
|
+
function addCountries(url, countriesAllowed, countriesBlocked) {
|
|
5
|
+
let tempUrl = url;
|
|
6
|
+
if (countriesAllowed != null) {
|
|
7
|
+
const tempUrlOne = new URL(tempUrl);
|
|
8
|
+
tempUrl += (tempUrlOne.search === "" ? "?" : "&") + "token_countries=" + countriesAllowed;
|
|
9
|
+
}
|
|
10
|
+
if (countriesBlocked != null) {
|
|
11
|
+
const tempUrlTwo = new URL(tempUrl);
|
|
12
|
+
tempUrl += (tempUrlTwo.search === "" ? "?" : "&") + "token_countries_blocked=" + countriesBlocked;
|
|
13
|
+
}
|
|
14
|
+
return tempUrl;
|
|
15
|
+
}
|
|
16
|
+
function signUrl(url, securityKey, expirationTime = 3600, userIp = null, isDirectory = false, pathAllowed = "", countriesAllowed = null, countriesBlocked = null) {
|
|
17
|
+
console.log("[BunnyHLS signUrl] Starting URL signing process");
|
|
18
|
+
console.log("[BunnyHLS signUrl] Input URL:", url);
|
|
19
|
+
console.log("[BunnyHLS signUrl] Parameters:", {
|
|
20
|
+
expirationTime,
|
|
21
|
+
userIp: userIp || "(none)",
|
|
22
|
+
isDirectory,
|
|
23
|
+
pathAllowed: pathAllowed || "(none)",
|
|
24
|
+
countriesAllowed: countriesAllowed || "(none)",
|
|
25
|
+
countriesBlocked: countriesBlocked || "(none)"
|
|
26
|
+
});
|
|
27
|
+
let parameterData = "";
|
|
28
|
+
let parameterDataUrl = "";
|
|
29
|
+
let signaturePath = "";
|
|
30
|
+
let hashableBase = "";
|
|
31
|
+
let token = "";
|
|
32
|
+
const expires = Math.floor((/* @__PURE__ */ new Date()).getTime() / 1e3) + expirationTime;
|
|
33
|
+
console.log("[BunnyHLS signUrl] Expiration timestamp:", expires, `(${expirationTime}s from now)`);
|
|
34
|
+
const urlWithCountries = addCountries(url, countriesAllowed, countriesBlocked);
|
|
35
|
+
const parsedUrl = new URL(urlWithCountries);
|
|
36
|
+
const parameters = new URL(urlWithCountries).searchParams;
|
|
37
|
+
if (pathAllowed !== "") {
|
|
38
|
+
signaturePath = pathAllowed;
|
|
39
|
+
parameters.set("token_path", signaturePath);
|
|
40
|
+
console.log("[BunnyHLS signUrl] Using pathAllowed:", signaturePath);
|
|
41
|
+
} else {
|
|
42
|
+
signaturePath = decodeURIComponent(parsedUrl.pathname);
|
|
43
|
+
console.log("[BunnyHLS signUrl] Using parsed pathname:", signaturePath);
|
|
44
|
+
}
|
|
45
|
+
const sortedParams = Array.from(parameters.entries()).sort();
|
|
46
|
+
console.log("[BunnyHLS signUrl] Sorted parameters:", sortedParams);
|
|
47
|
+
sortedParams.forEach(([key, value]) => {
|
|
48
|
+
if (value === "") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (parameterData.length > 0) {
|
|
52
|
+
parameterData += "&";
|
|
53
|
+
}
|
|
54
|
+
parameterData += key + "=" + value;
|
|
55
|
+
parameterDataUrl += "&" + key + "=" + querystring.escape(value);
|
|
56
|
+
});
|
|
57
|
+
console.log("[BunnyHLS signUrl] Parameter data:", parameterData);
|
|
58
|
+
console.log("[BunnyHLS signUrl] Parameter data URL:", parameterDataUrl);
|
|
59
|
+
hashableBase = securityKey + signaturePath + expires + (userIp != null ? userIp : "") + parameterData;
|
|
60
|
+
console.log("[BunnyHLS signUrl] Hashable base length:", hashableBase.length);
|
|
61
|
+
console.log("[BunnyHLS signUrl] Hashable base preview:", hashableBase.substring(0, 50) + "...");
|
|
62
|
+
token = Buffer.from(crypto.createHash("sha256").update(hashableBase).digest()).toString("base64");
|
|
63
|
+
token = token.replace(/\n/g, "").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
64
|
+
console.log("[BunnyHLS signUrl] Generated token:", token.substring(0, 20) + "...");
|
|
65
|
+
let finalUrl;
|
|
66
|
+
if (isDirectory) {
|
|
67
|
+
finalUrl = parsedUrl.protocol + "//" + parsedUrl.host + "/bcdn_token=" + token + parameterDataUrl + "&expires=" + expires + parsedUrl.pathname;
|
|
68
|
+
console.log("[BunnyHLS signUrl] Directory mode - Final URL generated");
|
|
69
|
+
} else {
|
|
70
|
+
finalUrl = parsedUrl.protocol + "//" + parsedUrl.host + parsedUrl.pathname + "?token=" + token + parameterDataUrl + "&expires=" + expires;
|
|
71
|
+
console.log("[BunnyHLS signUrl] File mode - Final URL generated");
|
|
72
|
+
}
|
|
73
|
+
console.log("[BunnyHLS signUrl] Signing complete");
|
|
74
|
+
return finalUrl;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/server/index.ts
|
|
78
|
+
var createBunnyHandler = (options) => {
|
|
79
|
+
const {
|
|
80
|
+
securityKey,
|
|
81
|
+
libraryHost,
|
|
82
|
+
expirationTime = 3600,
|
|
83
|
+
isDirectory = true
|
|
84
|
+
} = options;
|
|
85
|
+
return async (req, res) => {
|
|
86
|
+
console.log("[BunnyHLS] Handler called at", (/* @__PURE__ */ new Date()).toISOString());
|
|
87
|
+
let videoId;
|
|
88
|
+
if (req.nextUrl) {
|
|
89
|
+
videoId = req.nextUrl.searchParams.get("videoId") || "";
|
|
90
|
+
console.log("[BunnyHLS] App Router - Request URL:", req.nextUrl.toString());
|
|
91
|
+
} else if (req.query) {
|
|
92
|
+
videoId = req.query.videoId || "";
|
|
93
|
+
console.log("[BunnyHLS] Pages Router - Query params:", req.query);
|
|
94
|
+
} else {
|
|
95
|
+
videoId = "";
|
|
96
|
+
console.log("[BunnyHLS] No videoId found in request");
|
|
97
|
+
}
|
|
98
|
+
console.log("[BunnyHLS] Extracted videoId:", videoId || "(empty)");
|
|
99
|
+
if (!videoId) {
|
|
100
|
+
console.error("[BunnyHLS] ERROR: videoId is required");
|
|
101
|
+
const errorResponse = { error: "videoId is required" };
|
|
102
|
+
if (res) {
|
|
103
|
+
return res.status(400).json(errorResponse);
|
|
104
|
+
}
|
|
105
|
+
return new Response(JSON.stringify(errorResponse), {
|
|
106
|
+
status: 400,
|
|
107
|
+
headers: { "Content-Type": "application/json" }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const pathAllowed = `/${videoId}/`;
|
|
111
|
+
const url = `https://${libraryHost}/${videoId}/playlist.m3u8`;
|
|
112
|
+
console.log("[BunnyHLS] Signing URL:", url);
|
|
113
|
+
console.log("[BunnyHLS] Config:", {
|
|
114
|
+
libraryHost,
|
|
115
|
+
expirationTime,
|
|
116
|
+
isDirectory,
|
|
117
|
+
pathAllowed,
|
|
118
|
+
securityKeyLength: securityKey?.length || 0
|
|
119
|
+
});
|
|
120
|
+
const signedUrl = signUrl(
|
|
121
|
+
url,
|
|
122
|
+
securityKey,
|
|
123
|
+
expirationTime,
|
|
124
|
+
null,
|
|
125
|
+
isDirectory,
|
|
126
|
+
pathAllowed,
|
|
127
|
+
null,
|
|
128
|
+
null
|
|
129
|
+
);
|
|
130
|
+
console.log("[BunnyHLS] Signed URL generated:", signedUrl);
|
|
131
|
+
console.log("[BunnyHLS] Response prepared successfully");
|
|
132
|
+
const response = { signedUrl };
|
|
133
|
+
if (res) {
|
|
134
|
+
return res.status(200).json(response);
|
|
135
|
+
} else {
|
|
136
|
+
return new Response(JSON.stringify(response), {
|
|
137
|
+
status: 200,
|
|
138
|
+
headers: { "Content-Type": "application/json" }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
export {
|
|
144
|
+
createBunnyHandler
|
|
145
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bunnyhls",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Full-stack npm package for BunnyNet HLS video playback with React components and Next.js API handlers",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"private": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./client": "./dist/client/index.js",
|
|
11
|
+
"./server": "./dist/server/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node sign.js",
|
|
20
|
+
"build": "tsup src/client/index.ts src/server/index.ts --format cjs,esm --dts --clean",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"author": "Samuel Petros Kelbiso (sampk7)",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"hls.js": "^1.4.12"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.0.0",
|
|
30
|
+
"@types/react": "^18.0.0",
|
|
31
|
+
"typescript": "^5.0.0",
|
|
32
|
+
"tsup": "^8.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
36
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"react-dom": {
|
|
40
|
+
"optional": false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|