@growth-labs/video
v0.4.11
Published
Astro 6 integration for HLS video playback from R2, reusable R2 HLS bundle ingestion, captions/chapters sidecars, YouTube embeds, and Worker-proxied premium gating from a distinct private R2 origin.
Readme
@growth-labs/video
Astro 6 integration for HLS video playback from R2, reusable R2 HLS bundle ingestion, captions/chapters sidecars, YouTube embeds, and Worker-proxied premium gating from a distinct private R2 origin.
Install
pnpm add @growth-labs/video vidstackConfig
import video from '@growth-labs/video'
video({
publicDomain: 'media.example.com',
r2Binding: 'MEDIA_BUCKET',
admin: {
importRoute: {
enabled: true,
path: '/api/admin/video/import',
requireAuth: true,
},
},
})Premium playback additionally needs premium.enabled, a distinct private R2 binding, an accessCheckModule (a module-spec pointing at a file in your own codebase), and a GL_VIDEO_SESSION_SECRET secret of at least 32 bytes:
// astro.config.mjs
video({
publicDomain: 'media.example.com',
r2Binding: 'MEDIA_BUCKET', // public images and public video only
premium: {
enabled: true,
r2Binding: 'PREMIUM_MEDIA', // private bucket; no r2.dev or custom domain
sessionScope: 'per-video', // default; 'wildcard' opts into the 0.2.x behavior
sessionTtl: '1h', // duration string: '1h' | '30m' | '2h30m' | '45s'
accessCheckModule: { module: '/src/lib/video-access-check', export: 'check' },
},
})premium.r2Binding is required when premium.enabled=true and must differ from
the public r2Binding. The package rejects a shared binding at build time. A
different binding name is necessary but not sufficient: the consumer must bind
it to a genuinely separate bucket with both r2.dev and custom-domain access
disabled. The package reads protected objects only through the Worker binding;
the signed per-video session cookie is resource-bound and expires. Every
playlist and segment request re-runs the configured access check and requires
its current subjectId to match the subject bound into the signed session.
Revocation, logout, account deletion, or replay under another identity therefore
fails before any private R2 object read. Session payloads are strict: issuer and
verifier share the same bounded payload policy, unexpected signed payload keys
are rejected, and oversized issuer payloads fail before signing. Malformed
session cookies fail closed as the same protected 403 response.
For a controlled migration or incident recovery where an existing premium
catalog still lives in the public bucket, set premium.r2Binding to the public
binding and explicitly add allowPublicStorage: true. The authenticated route
still enforces the signed session, current access check, matching subject, and
private/no-store response policy before every R2 read. This is not private
storage: direct public object URLs remain fetchable. The option defaults to
false; remove it after copying the catalog to a distinct private bucket.
Per-subject placement selection
Consumers migrating rows between public and private storage can configure both bindings without duplicating playback routes:
premium: {
enabled: true,
r2Binding: 'PUBLIC_MEDIA',
allowPublicStorage: true,
placementBindings: {
public: 'PUBLIC_MEDIA',
private: 'PREMIUM_MEDIA',
},
accessCheckModule: { module: '/src/lib/video-access-check', export: 'check' },
}The consumer's access check resolves verified placement by stable subject ID:
return {
allowed: true,
subjectId: user.id,
playbackPlacement: placement
? { verified: true, storageClass: placement.storageClass }
: { verified: false },
}The package never infers placement from channel names, slugs, routes, or video
IDs. A verified class is signed into the session and must match the fresh access
result on every manifest, segment, and track request. Unverified, mismatched, or
unconfigured placement fails with a protected terminal error before R2 access;
there is no private-to-public fallback. Omit playbackPlacement entirely only
for the legacy premium.r2Binding path.
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "<site>-media"
[[r2_buckets]]
binding = "PREMIUM_MEDIA"
bucket_name = "<site>-premium-media"Provisioning buckets, bindings, domains, and the Secrets Store entry is an operator-owned deployment step. This package release does not mutate those surfaces.
// /src/lib/video-access-check.ts
import type { AccessCheck } from '@growth-labs/video'
export const check: AccessCheck = async ({ videoId, request, env }, context) => {
// Returns { allowed, reason?, subjectId? }
}Why a module spec instead of an inline function? The integration runs in the build-time Node process; the session route runs in the Worker runtime — a different process. An inline accessCheck registered at build time never reaches the Worker bundle, so every request would 500 with access_check_not_configured. The module-spec lets the Vite plugin emit a real static ES import at the top of the virtual:growth-labs/video/access-check module, so the function is bundled with the worker. Matches the same pattern as @growth-labs/auth's renderers (see auth 0.3.7+ MIGRATION.md).
per-video scope (the 0.3.0 default) issues a cookie restricted to one videoId.
Both scopes re-check current access and subject identity for every playlist and
segment request; wildcard only broadens which video IDs the signed session can
address. Keep sessionTtl short enough to limit stolen-cookie lifetime even
though replay without the bound authenticated identity is rejected.
Customizing the layout
Since 0.3.5, <Video> exposes a named slot layout for replacing the Vidstack chrome (controls, sliders, menus) without losing anything the package wires up around the player — premium bootstrap, accessCheck, gl:video-* analytics events, captions/chapter <track> elements, progress marks.
---
import { Video } from '@growth-labs/video/components'
import MyVideoLayout from '../components/MyVideoLayout.astro'
---
<Video videoId="..." title="..." premium>
<MyVideoLayout slot="layout" />
</Video>If you omit the slot, you get Vidstack's stock <media-video-layout> (the package's current default). If you provide it, your slotted content replaces the default entirely — it is rendered as a direct child of <media-player>, so any Vidstack layout primitive (<media-controls>, <media-time-slider>, <media-quality-radio-group>, etc.) works.
Consumers writing a custom layout typically need to import Vidstack's full UI element registry once at the top of their component (Vidstack's vidstack/elements only defines a minimal set; the full primitive set is under vidstack/player/ui):
// inside MyVideoLayout.astro's <script>
import 'vidstack/player/ui'Captions and chapter <track> elements continue to be rendered by <Video> inside <media-provider> — do not duplicate them inside your slotted layout.
R2 Layout
video/<videoId>/hls/master.m3u8
video/<videoId>/hls/<rendition>/index.m3u8
video/<videoId>/hls/<rendition>/init.mp4
video/<videoId>/hls/<rendition>/segment-00001.m4s
video/<videoId>/poster.jpg
video/<videoId>/text/captions_en.vtt
video/<videoId>/text/chapters.vttIngest Helpers
import { ingestHlsBundle, writeCaptionsVtt, writeChaptersVtt } from '@growth-labs/video/utils'
await ingestHlsBundle({
bucket: env.MEDIA_BUCKET,
publicDomain: 'media.example.com',
videoId: 'intro-video',
files: [
{ path: 'master.m3u8', body: masterManifest },
{ path: 'h264/720p/index.m3u8', body: renditionManifest },
{ path: 'h264/720p/init.mp4', body: initBytes },
{ path: 'h264/720p/segment-00001.m4s', body: segmentBytes },
],
poster: { path: 'poster.jpg', body: posterBytes, contentType: 'image/jpeg' },
})
await writeCaptionsVtt({ bucket: env.MEDIA_BUCKET, videoId: 'intro-video', vtt: captionsVtt })
await writeChaptersVtt({ bucket: env.MEDIA_BUCKET, videoId: 'intro-video', vtt: chaptersVtt })The ingestion helper is the public-bundle path: it validates master.m3u8, referenced rendition playlists, segment paths, and local HLS URI="..." attributes such as EXT-X-MAP, EXT-X-MEDIA, EXT-X-I-FRAME-STREAM-INF, and EXT-X-KEY before writing to R2 and returning a public manifest URL. Protected bundles are written by the owner publishing pipeline to premium.r2Binding; never persist or expose a public origin URL for them. Absolute/external URLs and unsafe relative paths are rejected. Transcoding is out of scope; pass already-generated HLS and VTT files.
All R2 key helpers normalize videoId with the same safe contract: IDs must start with an ASCII letter or number and then contain only ASCII letters, numbers, _, or -. Text-track helpers write only under video/<videoId>/text/captions_<lang>.vtt and video/<videoId>/text/chapters.vtt.
Injected routes read bindings from cloudflare:workers env; no locals.runtime shim is required.
Premium access verification
The repository-level verifier proves the deployed route and legacy origin in a
single redacted run. PREMIUM_HLS_TEST_FIXTURE contains an absolute path to a
regular JSON file with mode 0600; it never contains the fixture value itself.
{
"masterUrl": "https://site.example/api/video/playback/<resource>/hls/master.m3u8",
"authorizedCookie": "gl_video_session=<owner-provisioned-authorized-cookie>",
"expiredCookie": "gl_video_session=<owner-provisioned-expired-cookie>",
"wrongAudienceCookie": "gl_video_session=<owner-provisioned-wrong-resource-cookie>",
"nonEntitledCookie": "fronts_session=<owner-provisioned-non-entitled-session>",
"publicOriginMasterUrl": "https://media.example/video/<resource>/hls/master.m3u8",
"publicOriginSegmentUrl": "https://media.example/video/<resource>/hls/<rendition>/<segment>"
}pnpm run hls:access:verify -- --fixture-env PREMIUM_HLS_TEST_FIXTUREThe verifier requires 403 for anonymous, expired, wrong-audience, and non-entitled gateway requests, 200 for authorized playlists/segments, 206 for an authorized range, and 403 or 404 for the corresponding unsigned legacy-origin objects. It follows no redirects, rejects cross-origin and path-escaping manifest references, bounds all manifest reads, and prints only named checks and status codes—never URLs, paths, cookies, manifests, or media bytes.
