diff --git a/js/docs/src/content/docs/developers.mdx b/js/docs/src/content/docs/developers.mdx
index 272070d05..bf98ad928 100644
--- a/js/docs/src/content/docs/developers.mdx
+++ b/js/docs/src/content/docs/developers.mdx
@@ -21,7 +21,7 @@ import { Card, CardGrid } from "@astrojs/starlight/components";
Run your own Streamplace infrastructure. - [Installation
- guide](/docs/guides/installing/installing-streamplace)
+ guide](/docs/guides/installing/downloading-streamplace)
{" "}
--
2.54.0
From ec33d544f2b79c5d74ec560413738fcb6fd4ff0b Mon Sep 17 00:00:00 2001
From: Natalie Bridgers
Date: Fri, 14 Aug 2026 14:08:25 -0500
Subject: [PATCH 02/76] pin node version to supported so it builds right when
usign workers (lol)
Signed-off-by: Natalie Bridgers
---
.nvmrc | 1 +
1 file changed, 1 insertion(+)
create mode 100644 .nvmrc
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..2bd5a0a98
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
--
2.54.0
From 7c4148c60979754b8edb2ceb688eeaf3478984c3 Mon Sep 17 00:00:00 2001
From: Natalie Bridgers
Date: Tue, 18 Aug 2026 20:54:45 -0500
Subject: [PATCH 03/76] i hate CI
Signed-off-by: Natalie Bridgers
---
js/docs/.nvmrc | 1 +
1 file changed, 1 insertion(+)
create mode 100644 js/docs/.nvmrc
diff --git a/js/docs/.nvmrc b/js/docs/.nvmrc
new file mode 100644
index 000000000..2bd5a0a98
--- /dev/null
+++ b/js/docs/.nvmrc
@@ -0,0 +1 @@
+22
--
2.54.0
From b6070de0247d2a813f86b389488046986dee0f0f Mon Sep 17 00:00:00 2001
From: Joshua <1041261+JoshuaACasey@users.noreply.github.com>
Date: Tue, 6 Oct 2026 03:59:11 -0500
Subject: [PATCH 04/76] fix missing streamplace-logo.svg in README.md
---
streamplace-logo.svg | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 streamplace-logo.svg
diff --git a/streamplace-logo.svg b/streamplace-logo.svg
new file mode 100644
index 000000000..1b829f028
--- /dev/null
+++ b/streamplace-logo.svg
@@ -0,0 +1,17 @@
+
+
--
2.54.0
From e41b01651b76380241f6f36627dc500d262b23a0 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Thu, 8 Oct 2026 16:31:53 -0700
Subject: [PATCH 05/76] deps: bump muxl to carry unknown track groups as opaque
tracks
Pins github.com/streamplace/muxl/go to streamplace/muxl#7 (dbacdfe).
muxl readers now carry canonical segments of track groups they don't
decode (e.g. upcoming transcript/text tracks) through unwrap, events,
metafiles and flat wraps instead of failing with "segment catalog
describes no track"; only moov synthesis and the appendable fMP4 leave
them out. Event bytes still reassemble to the stored blob, so the VOD
metafile's byte-range offsets stay correct.
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 230d9dc5d..d3b3f3e71 100644
--- a/go.mod
+++ b/go.mod
@@ -73,7 +73,7 @@ require (
github.com/streamplace/atmoq/go v0.0.4-0.20260701223355-13757de4ae08
github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c
github.com/streamplace/glex v0.0.0-20260820164827-814f46540f22
- github.com/streamplace/muxl/go v0.3.5
+ github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512
github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b
github.com/stretchr/testify v1.11.1
github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71
diff --git a/go.sum b/go.sum
index fa86cf1c4..3b8402224 100644
--- a/go.sum
+++ b/go.sum
@@ -1397,8 +1397,8 @@ github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAy
github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
-github.com/streamplace/muxl/go v0.3.5 h1:sp33xrcblpl1nfKVXy03sDfQd7rM2aizbIaYajUH2zg=
-github.com/streamplace/muxl/go v0.3.5/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
+github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512 h1:Xd5iaNxwHXGOSOHJkC/X0eyo8r4Pz/NDB8WOjislZbo=
+github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b h1:eWbwCtBbMyrDTHLYIold07OR2hmvzXsbAUxi57ElMLk=
github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b/go.mod h1:wpY+T/wE00jrUhgh2dKXbbE91D36u86KGlENK/hWFkE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
--
2.54.0
From c3868dc5184d550a0fcb639e9c7c230c177501ca Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 9 Oct 2026 14:28:11 -0700
Subject: [PATCH 06/76] deps: pin muxl to v0.3.6 release
Replaces the streamplace/muxl#7 pseudo-version with the v0.3.6 tag,
which contains #7 (opaque tracks for unknown track groups).
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index d3b3f3e71..2db94e391 100644
--- a/go.mod
+++ b/go.mod
@@ -73,7 +73,7 @@ require (
github.com/streamplace/atmoq/go v0.0.4-0.20260701223355-13757de4ae08
github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c
github.com/streamplace/glex v0.0.0-20260820164827-814f46540f22
- github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512
+ github.com/streamplace/muxl/go v0.3.6
github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b
github.com/stretchr/testify v1.11.1
github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71
diff --git a/go.sum b/go.sum
index 3b8402224..b87120386 100644
--- a/go.sum
+++ b/go.sum
@@ -1397,8 +1397,8 @@ github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAy
github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
-github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512 h1:Xd5iaNxwHXGOSOHJkC/X0eyo8r4Pz/NDB8WOjislZbo=
-github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
+github.com/streamplace/muxl/go v0.3.6 h1:lyWnY15nft/0GK3LBA4OkD2aHrLV9RRqI+YcDRPFk3g=
+github.com/streamplace/muxl/go v0.3.6/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b h1:eWbwCtBbMyrDTHLYIold07OR2hmvzXsbAUxi57ElMLk=
github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b/go.mod h1:wpY+T/wE00jrUhgh2dKXbbE91D36u86KGlENK/hWFkE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
--
2.54.0
From e324ef759146352685d689efee671c4008b7d2de Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 9 Oct 2026 20:42:19 -0700
Subject: [PATCH 07/76] app: signed-out About link in the top-level sidebar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
About only lived under Settings -> About, which nobody signed out would think
to open. The sidebar now carries an About entry pointing at the same page
(/settings/about), shown only while signed out; signed in it stays in the
settings menu with the rest of the account chrome.
Drops the orphaned marketing About screen that the top-level /about path
rendered — nothing linked to it, and /settings/about is the About page now.
The sidebar also stops lighting up a prefix-matching entry (Settings spans
/settings/*) when a sibling entry matches the current path exactly, so only
About highlights on /settings/about.
Web e2e covers both states (flows/about-link.spec.ts).
---
js/app/components/sidebar/sidebar-overlay.tsx | 26 ++-
js/app/src/linking-config.ts | 2 -
js/app/src/navigation-helper.tsx | 1 -
js/app/src/navigation-types.ts | 1 -
js/app/src/screens/about.tsx | 181 ------------------
js/app/src/shell.tsx | 9 -
js/e2e-web/flows/about-link.spec.ts | 48 +++++
7 files changed, 71 insertions(+), 197 deletions(-)
delete mode 100644 js/app/src/screens/about.tsx
create mode 100644 js/e2e-web/flows/about-link.spec.ts
diff --git a/js/app/components/sidebar/sidebar-overlay.tsx b/js/app/components/sidebar/sidebar-overlay.tsx
index 1df4febd0..d62ef452e 100644
--- a/js/app/components/sidebar/sidebar-overlay.tsx
+++ b/js/app/components/sidebar/sidebar-overlay.tsx
@@ -29,6 +29,7 @@ import {
Download,
Hash,
Home,
+ Info,
Library,
Link as LinkIcon,
List,
@@ -494,6 +495,15 @@ export function SidebarOverlay() {
// the sidebar — this row is just the logged-out "Log in" entry.
const secondaryItems: SidebarNavItem[] = [
{ icon: LogIn, label: "Log in", href: "/login", hidden: !!did },
+ {
+ icon: Info,
+ // Signed out, About gets a top-level entry instead of hiding two levels
+ // deep under Settings; signed in it stays in the settings menu, where the
+ // rest of the account chrome lives.
+ label: "About",
+ href: "/settings/about",
+ hidden: !!did,
+ },
{
icon: SettingsIcon,
label: "Settings",
@@ -553,8 +563,13 @@ export function SidebarOverlay() {
);
- const renderItems = (items: SidebarNavItem[]) =>
- items.map((item) => {
+ const renderItems = (items: SidebarNavItem[]) => {
+ // A prefix match (Settings spans /settings/*) yields when a sibling entry
+ // matches the current path exactly, so only the page you're on lights up.
+ const onExactEntry = items.some(
+ (item) => !item.hidden && item.href === currentPath,
+ );
+ return items.map((item) => {
if (item.hidden) return null;
return (
{
e.preventDefault();
@@ -571,6 +590,7 @@ export function SidebarOverlay() {
/>
);
});
+ };
return (
-
-
-
-
-
- {title}
-
-
- {body}
-
-
-
- );
-}
-
-export default function AboutScreen() {
- const { theme } = useTheme();
- const c = theme.colors;
- return (
-
-
- {/* Hero */}
-
-
-
-
- What is Streamplace?
-
-
- The video layer for the open social web.
-
-
- Open-source infrastructure bringing high-quality, creator-owned
- video to the AT Protocol — designed around user sovereignty and
- content authenticity.
-
-
-
-
- {/* Feature list */}
-
- {FEATURES.map((f) => (
-
- ))}
-
-
- {/* Get-involved CTA */}
-
-
- Want to get involved?
-
-
- Join our Discord to learn more about Streamplace and how you can
- help build it.
-
-
- }
- onPress={() => Linking.openURL("https://discord.stream.place")}
- >
- Join our Discord
-
-
-
-
-
- );
-}
diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx
index 791eed444..3fa4b9aeb 100644
--- a/js/app/src/shell.tsx
+++ b/js/app/src/shell.tsx
@@ -57,7 +57,6 @@ import { Platform, Pressable, StatusBar, View } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { SFSymbols7_0 } from "sf-symbols-typescript";
import "src/navigation-types";
-import AboutScreen from "src/screens/about";
import AppReturnScreen from "src/screens/app-return";
import PopoutChat from "src/screens/chat-popout";
import DanmuOBSScreen from "src/screens/danmu-obs";
@@ -222,14 +221,6 @@ function HomeNavigator() {
}),
}}
/>
-
About once you are signed in.
+const HTTPS_URL = process.env.SERVER_HTTPS_URL;
+
+test("about-link: signed out, the sidebar links straight to the About page", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await expect(page.getByTestId("home-stream-card").first()).toBeVisible({
+ timeout: 30_000,
+ });
+
+ await page.getByRole("link", { name: "About", exact: true }).first().click();
+ await expect(page).toHaveURL(/\/settings\/about$/);
+ await expect(page.getByText(/^Streamplace v/).first()).toBeVisible();
+});
+
+test("about-link: signed in, About stays in the settings menu", async ({
+ page,
+}) => {
+ test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames");
+ await loginThroughPds(page);
+
+ await page.goto(`${HTTPS_URL}/`);
+ await expect(page.getByTestId("home-stream-card").first()).toBeVisible({
+ timeout: 30_000,
+ });
+ // the sidebar drops "Log in" once the session lands, so it is showing the
+ // signed-in list by the time the absence check runs
+ await expect(
+ page.getByRole("link", { name: "Log in", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("link", { name: "About", exact: true }),
+ ).toHaveCount(0);
+
+ await page
+ .getByRole("link", { name: "Settings", exact: true })
+ .first()
+ .click();
+ await expect(page.getByText("Advanced").first()).toBeVisible();
+ await page.getByText("About", { exact: true }).first().click();
+ await expect(page).toHaveURL(/\/settings\/about$/);
+});
--
2.54.0
From 96156d7e4c43eeee944381eba3a3a16d89e7349e Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 9 Oct 2026 21:02:25 -0700
Subject: [PATCH 08/76] sidebar: mark the current row with aria-current so the
highlight is visible
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Greptile flagged that the exact-match highlight rule had no coverage — the
active pill was the only cue, and it is invisible to assistive tech as well as
to tests. Expose it as aria-current=page and assert both directions in the
signed-out about-link flow: About (not Settings) is current on
/settings/about, Settings is current on /settings/advanced.
---
js/app/components/sidebar/sidebar-item.tsx | 3 ++
js/e2e-web/flows/about-link.spec.ts | 45 +++++++++++++++-------
2 files changed, 35 insertions(+), 13 deletions(-)
diff --git a/js/app/components/sidebar/sidebar-item.tsx b/js/app/components/sidebar/sidebar-item.tsx
index dd3044323..303512f7e 100644
--- a/js/app/components/sidebar/sidebar-item.tsx
+++ b/js/app/components/sidebar/sidebar-item.tsx
@@ -74,6 +74,9 @@ export default function SidebarItem({
onHoverIn={() => setHover(true)}
onHoverOut={() => setHover(false)}
role="link"
+ // The current destination. The filled pill is the only other cue, which
+ // assistive tech (and e2e) can't see.
+ aria-current={active ? "page" : undefined}
accessibilityLabel={typeof label === "string" ? label : "Link to " + href}
// @ts-ignore This makes it render as on web!
href={href}
diff --git a/js/e2e-web/flows/about-link.spec.ts b/js/e2e-web/flows/about-link.spec.ts
index 57a686b42..e7aa82e9a 100644
--- a/js/e2e-web/flows/about-link.spec.ts
+++ b/js/e2e-web/flows/about-link.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, test, type Page } from "@playwright/test";
import { loginThroughPds } from "./login";
// The About entry is a top-level sidebar destination while signed out — the
@@ -6,6 +6,9 @@ import { loginThroughPds } from "./login";
// folds back into Settings > About once you are signed in.
const HTTPS_URL = process.env.SERVER_HTTPS_URL;
+const sidebarLink = (page: Page, name: string) =>
+ page.getByRole("link", { name, exact: true }).first();
+
test("about-link: signed out, the sidebar links straight to the About page", async ({
page,
}) => {
@@ -14,9 +17,32 @@ test("about-link: signed out, the sidebar links straight to the About page", asy
timeout: 30_000,
});
- await page.getByRole("link", { name: "About", exact: true }).first().click();
+ await sidebarLink(page, "About").click();
await expect(page).toHaveURL(/\/settings\/about$/);
await expect(page.getByText(/^Streamplace v/).first()).toBeVisible();
+
+ // Only the destination you're on lights up, though Settings matches every
+ // /settings/* path by prefix.
+ await expect(sidebarLink(page, "About")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ await expect(sidebarLink(page, "Settings")).not.toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+
+ // ...and that prefix still carries every other settings page.
+ await page.goto("/settings/advanced");
+ await expect(page.getByText("Advanced").first()).toBeVisible();
+ await expect(sidebarLink(page, "Settings")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ await expect(sidebarLink(page, "About")).not.toHaveAttribute(
+ "aria-current",
+ "page",
+ );
});
test("about-link: signed in, About stays in the settings menu", async ({
@@ -31,17 +57,10 @@ test("about-link: signed in, About stays in the settings menu", async ({
});
// the sidebar drops "Log in" once the session lands, so it is showing the
// signed-in list by the time the absence check runs
- await expect(
- page.getByRole("link", { name: "Log in", exact: true }),
- ).toHaveCount(0);
- await expect(
- page.getByRole("link", { name: "About", exact: true }),
- ).toHaveCount(0);
-
- await page
- .getByRole("link", { name: "Settings", exact: true })
- .first()
- .click();
+ await expect(sidebarLink(page, "Log in")).toHaveCount(0);
+ await expect(sidebarLink(page, "About")).toHaveCount(0);
+
+ await sidebarLink(page, "Settings").click();
await expect(page.getByText("Advanced").first()).toBeVisible();
await page.getByText("About", { exact: true }).first().click();
await expect(page).toHaveURL(/\/settings\/about$/);
--
2.54.0
From 702aae979c367ea5842e02e68790fc08ec2cccdb Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 11 Oct 2026 15:50:31 -0700
Subject: [PATCH 09/76] fix shadow input idle accounting and paced EOF fixture
---
pkg/api/duplicate_mist.go | 73 ++++++++++++++++++++++++++--------
pkg/api/duplicate_mist_test.go | 52 ++++++++++++++++++++++++
pkg/cmd/duplicate_mist_test.go | 52 +++++++++++++++++-------
pkg/rtmps/shadow.go | 18 +++++----
4 files changed, 157 insertions(+), 38 deletions(-)
diff --git a/pkg/api/duplicate_mist.go b/pkg/api/duplicate_mist.go
index df0349a0e..ce71a02d9 100644
--- a/pkg/api/duplicate_mist.go
+++ b/pkg/api/duplicate_mist.go
@@ -7,7 +7,7 @@ import (
"fmt"
"io"
"net"
- "sync/atomic"
+ "sync"
"time"
"github.com/bluenviron/gortmplib"
@@ -24,22 +24,68 @@ func (replayRTMP) Write(p []byte) (int, error) { return len(p), nil }
var errShadowIdleTimeout = errors.New("shadow RTMP input idle timeout")
+// Only a pending input Read is idle. Parsing buffered bytes, initializing native
+// media, and draining after EOF must not consume the publisher's idle allowance.
type shadowIdleReader struct {
io.Reader
- timer *time.Timer
+ mu sync.Mutex
+ timer *time.Timer
+ deadline time.Time
+ timedOut bool
+ cancel context.CancelFunc
}
-func (r shadowIdleReader) Read(p []byte) (int, error) {
+func newShadowIdleReader(input io.Reader, cancel context.CancelFunc) *shadowIdleReader {
+ r := &shadowIdleReader{Reader: input, cancel: cancel}
+ r.timer = time.AfterFunc(RTMPTimeout, r.expire)
+ r.timer.Stop()
+ return r
+}
+
+func (r *shadowIdleReader) expire() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.deadline.IsZero() {
+ return
+ }
+ if remaining := time.Until(r.deadline); remaining > 0 {
+ // Stop cannot retract a callback already queued by an earlier Read.
+ // That callback must honor the current Read's full idle allowance.
+ r.timer.Reset(remaining)
+ return
+ }
+ r.timedOut = true
+ r.cancel()
+}
+
+func (r *shadowIdleReader) Read(p []byte) (int, error) {
+ r.mu.Lock()
+ if r.timedOut {
+ r.mu.Unlock()
+ return 0, errShadowIdleTimeout
+ }
+ r.deadline = time.Now().Add(RTMPTimeout)
+ r.timer.Reset(RTMPTimeout)
+ r.mu.Unlock()
n, err := r.Reader.Read(p)
- if err != nil {
- // Publisher EOF starts downstream drain, not another input deadline.
- r.timer.Stop()
- } else if n > 0 {
- r.timer.Reset(RTMPTimeout)
+ r.mu.Lock()
+ r.deadline = time.Time{}
+ r.timer.Stop()
+ if r.timedOut {
+ err = errShadowIdleTimeout
}
+ r.mu.Unlock()
return n, err
}
+func (r *shadowIdleReader) stop() bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.deadline = time.Time{}
+ r.timer.Stop()
+ return r.timedOut
+}
+
// RunDuplicateMistWorker consumes the decrypted, client-only RTMP conversation.
// gortmplib's non-strict plain handshake consumes C2 without comparing it to
// the shadow's S1, so the publisher's response to Mist's handshake is valid.
@@ -57,19 +103,14 @@ func RunDuplicateMistWorker(parent context.Context, input io.Reader, progress io
}
})
defer stopInput()
- var timedOut atomic.Bool
- idle := time.AfterFunc(RTMPTimeout, func() {
- timedOut.Store(true)
- cancel()
- })
+ inputReader := newShadowIdleReader(input, cancel)
defer func() {
- idle.Stop()
- if timedOut.Load() {
+ if inputReader.stop() {
retErr = errShadowIdleTimeout
}
}()
- br := bufio.NewReader(shadowIdleReader{Reader: input, timer: idle})
+ br := bufio.NewReader(inputReader)
version, err := br.Peek(1)
if err != nil {
return fmt.Errorf("shadow RTMP handshake: %w", err)
diff --git a/pkg/api/duplicate_mist_test.go b/pkg/api/duplicate_mist_test.go
index df228d27b..18c45b102 100644
--- a/pkg/api/duplicate_mist_test.go
+++ b/pkg/api/duplicate_mist_test.go
@@ -2,14 +2,66 @@ package api
import (
"bytes"
+ "context"
"fmt"
"io"
"testing"
+ "testing/synctest"
"time"
"github.com/stretchr/testify/require"
)
+func TestShadowIdleReaderAllowsConsumerWork(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ reader := newShadowIdleReader(bytes.NewReader([]byte{3}), cancel)
+ defer reader.stop()
+ var buf [1]byte
+ n, err := reader.Read(buf[:])
+ require.NoError(t, err)
+ require.Equal(t, 1, n)
+
+ // A caller processing bytes is not waiting on idle publisher input.
+ time.Sleep(2 * RTMPTimeout)
+ require.NoError(t, ctx.Err())
+ n, err = reader.Read(buf[:])
+ require.ErrorIs(t, err, io.EOF)
+ require.Zero(t, n)
+ time.Sleep(2 * RTMPTimeout)
+ require.NoError(t, ctx.Err(), "publisher EOF must allow downstream drain")
+ })
+}
+
+func TestShadowIdleReaderStaleCallbackHonorsPendingRead(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ input, publisher := io.Pipe()
+ defer input.Close()
+ defer publisher.Close()
+ stopInput := context.AfterFunc(ctx, func() { _ = input.Close() })
+ defer stopInput()
+ reader := newShadowIdleReader(input, cancel)
+ defer reader.stop()
+ result := make(chan error, 1)
+ go func() {
+ var buf [1]byte
+ _, err := reader.Read(buf[:])
+ result <- err
+ }()
+ synctest.Wait()
+ time.Sleep(RTMPTimeout / 2)
+ // A previous timer callback can already be queued when a new Read starts.
+ reader.expire()
+ require.NoError(t, ctx.Err(), "an old callback must not shorten this Read")
+ time.Sleep(RTMPTimeout / 2)
+ require.ErrorIs(t, <-result, errShadowIdleTimeout)
+ require.ErrorIs(t, ctx.Err(), context.Canceled)
+ })
+}
+
func TestDuplicateMistIdleHandshakeClosesOnlyShadowInput(t *testing.T) {
input, publisher := io.Pipe()
defer input.Close()
diff --git a/pkg/cmd/duplicate_mist_test.go b/pkg/cmd/duplicate_mist_test.go
index f45657477..31e90548e 100644
--- a/pkg/cmd/duplicate_mist_test.go
+++ b/pkg/cmd/duplicate_mist_test.go
@@ -19,6 +19,8 @@ import (
"time"
"github.com/bluenviron/gortmplib"
+ "github.com/bluenviron/gortmplib/pkg/bytecounter"
+ "github.com/bluenviron/gortmplib/pkg/message"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/go-gst/go-gst/gst"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -251,16 +253,36 @@ func TestDuplicateMistEndToEnd(t *testing.T) {
stop := context.AfterFunc(ctx, func() { client.Close(); server.Close() })
defer stop()
replies := make(chan struct{})
- go func() { _, _ = io.Copy(client, server); client.Close(); close(replies) }()
+ go func() {
+ _, _ = io.Copy(client, server)
+ client.Close()
+ // The encoder can finish before the TLS publisher's EOF gate opens.
+ _, _ = io.Copy(io.Discard, server)
+ close(replies)
+ }()
forwarded, err := io.Copy(server, client)
if err == nil {
- // Keep the actual TLS publisher connected until the parent has
- // observed live verification, even if encoding already reached EOS.
- select {
- case <-publisherEOF:
- case <-ctx.Done():
- err = ctx.Err()
- }
+ // Keep the actual TLS publisher active until live verification,
+ // even when encoding reaches EOS before a slow worker catches up.
+ counter := bytecounter.NewWriter(server)
+ keepalive := message.NewWriter(counter, counter, false)
+ err = func() error {
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-publisherEOF:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ if err := keepalive.Write(&message.UserControlPingRequest{}); err != nil {
+ return err
+ }
+ }
+ }
+ }()
+ forwarded += int64(counter.Count())
}
if corrupt && err == nil {
// Invalid RTMP after verified media must produce a failure.
@@ -296,6 +318,13 @@ func TestDuplicateMistEndToEnd(t *testing.T) {
busDone := make(chan error, 1)
go func() { busDone <- media.HandleBusMessages(ctx, pipeline) }()
require.NoError(t, pipeline.SetState(gst.StatePlaying))
+ select {
+ case err := <-busDone:
+ require.NoError(t, err)
+ case <-ctx.Done():
+ t.Fatal("publisher timed out")
+ }
+ require.NoError(t, pipeline.SetState(gst.StateNull))
if paced {
// The TLS publisher has not sent EOF; progress cannot be deferred
// until worker completion. Encoding speed is irrelevant.
@@ -305,13 +334,6 @@ func TestDuplicateMistEndToEnd(t *testing.T) {
}
close(publisherEOF)
select {
- case err := <-busDone:
- require.NoError(t, err)
- case <-ctx.Done():
- t.Fatal("publisher timed out")
- }
- require.NoError(t, pipeline.SetState(gst.StateNull))
- select {
case err := <-primaryResult:
require.NoError(t, err)
case <-ctx.Done():
diff --git a/pkg/rtmps/shadow.go b/pkg/rtmps/shadow.go
index 05e73570b..7121185e0 100644
--- a/pkg/rtmps/shadow.go
+++ b/pkg/rtmps/shadow.go
@@ -69,10 +69,12 @@ publisher identifiers or stream keys; use the failure logs for details.
The shadow has an 8 MiB per-connection buffer and a cap of 16 workers per
addon listener, including draining workers. At capacity, new connections
-forward only to Mist and log a shadow failure. Shadow input idle for 10
-seconds is aborted without closing the primary connection. Buffer overflow
-also kills only the shadow; Mist continues unchanged. On disconnect it
-drains the native pipeline through EOS and verifies the final segment.
+forward only to Mist and log a shadow failure. A pending shadow input read
+idle for 10 seconds is aborted without closing the primary connection.
+Parsing buffered input, native initialization, and signing do not consume
+this idle allowance. Buffer overflow also kills only the shadow; Mist
+continues unchanged. On disconnect it drains the native pipeline through
+EOS and verifies the final segment.
The playback handshake deadline is cleared for this drain; the 30-second
worker-exit deadline still applies. Node shutdown kills/reaps workers.
This is opt-in diagnostic work with additional CPU and memory cost, up to
@@ -84,9 +86,11 @@ The backend end-to-end regression is `TestDuplicateMistEndToEnd` in
through verified segments beyond the former few-second failure interval,
including a publisher outside `/live/` and all final segments after paced
and unpaced publisher EOF.
-The fixture holds TLS publisher EOF until live metrics show verification,
-so slow worker startup is not confused with a metrics failure. Corrupt RTMP
-must export a failure while subsequent primary traffic still arrives.
+The fixture stops the encoder at EOS but holds TLS publisher EOF until live
+metrics show verification, sending RTMP keepalives while it waits. Slow
+worker startup or signing is not confused with idle publisher input.
+Corrupt RTMP must export a failure while subsequent primary traffic still
+arrives.
No client UI or platform-specific iOS/Android/Web behavior changes.
*/
--
2.54.0
From c85e04317f39f03319b35558ebecbe2c6662c440 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 11 Oct 2026 15:53:30 -0700
Subject: [PATCH 10/76] chat: keep long handles inside the web chat column
A handle wider than the chat column stretched the compact web row past the
list. The chat list is a vertical ScrollView, which react-native-web renders
with overflow-x: hidden, so the colon and the message body were clipped at the
right edge with no way to scroll to them.
The trigger box around the handle is a View inside a Text, i.e. an inline-level
flex box, so its shrink-to-fit width followed its max-content, and the handle
group inside it could not shrink at all (react-native-web's View default is
flexShrink 0).
- Cap the trigger box at maxWidth 100%; its containing block is the message
body, which has a definite width.
- Let the handle group and the message body shrink below their min-content
width (flexShrink/minWidth 0).
- Clamp the handle to one line (numberOfLines) so it ellipsizes, with the colon
still riding at its end.
An unbreakable word in the message body now breaks mid-word instead of widening
the row the same way.
js/e2e-web/flows/chat-long-handle.spec.ts covers both: it seeds a second PDS
account whose handle is one long label (the PDS caps labels at 18 characters
under its service domain) and asserts the chat list has no horizontal overflow,
that the handle ellipsizes with the colon still after it, and that an
unbreakable body word stays inside the column. Both flows fail on the unfixed
tree.
---
.../src/components/chat/chat-message.tsx | 34 ++-
.../src/components/chat/user-profile-card.tsx | 6 +
js/e2e-web/flows/chat-long-handle.spec.ts | 229 ++++++++++++++++++
3 files changed, 257 insertions(+), 12 deletions(-)
create mode 100644 js/e2e-web/flows/chat-long-handle.spec.ts
diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx
index 599d8a415..25e7996dc 100644
--- a/js/components/src/components/chat/chat-message.tsx
+++ b/js/components/src/components/chat/chat-message.tsx
@@ -205,33 +205,43 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => {
// so words fill the space after the handle and wrap naturally, the
// way an unselectable row did. selectableText opts the flow back into web
// text selection.
-
+ //
+ // A handle wider than the row used to stretch the row past the chat
+ // column, whose scroll container hides horizontal overflow, putting the
+ // colon and the message out of reach. The handle clamps to one line and
+ // ellipsizes instead: minWidth 0 lets the group (react-native-web's View
+ // default is flexShrink 0) and this shrink below their min-content
+ // width, and the trigger box around the handle is capped at 100% in
+ // UserProfileCard.
+
{formatHandleWithAt(item.author)}
- {/* The colon rides in the handle's inline box so the pair wraps as
- one unit instead of the colon dropping to the next line. */}
+ {/* The colon rides in the handle's box so the pair stays one unit on
+ one line instead of the colon dropping to the next line. */}
{":"}
diff --git a/js/components/src/components/chat/user-profile-card.tsx b/js/components/src/components/chat/user-profile-card.tsx
index f2bf3e5f9..58c27aac5 100644
--- a/js/components/src/components/chat/user-profile-card.tsx
+++ b/js/components/src/components/chat/user-profile-card.tsx
@@ -464,6 +464,12 @@ export const UserProfileCard = ({
marginLeft: -3,
marginRight: -2,
marginBottom: -4,
+ // A View inside a Text is an inline-level flex box, so its
+ // shrink-to-fit width is driven by its max-content unless it is
+ // capped here. The compact chat row's handle lives in this box, so
+ // without the cap a handle longer than the row would size this box
+ // past the chat column and take the message with it.
+ maxWidth: "100%",
...(hovered ? { backgroundColor: theme.colors.surfaceHover } : {}),
},
]}
diff --git a/js/e2e-web/flows/chat-long-handle.spec.ts b/js/e2e-web/flows/chat-long-handle.spec.ts
new file mode 100644
index 000000000..6e24e1c2b
--- /dev/null
+++ b/js/e2e-web/flows/chat-long-handle.spec.ts
@@ -0,0 +1,229 @@
+import {
+ expect,
+ request,
+ test,
+ type APIRequestContext,
+ type Page,
+} from "@playwright/test";
+
+// The web chat row renders the handle, the colon and the message as one inline
+// flow. The trigger box around the handle could not shrink, so it stayed at its
+// max-content width: a handle longer than the chat column stretched the row past
+// the list, and the scroll container's overflow-x: hidden (react-native-web
+// hides it for a vertical ScrollView) put the rest of the message, colon and
+// all, out of reach with no way to scroll to it. The handle is now a single-line
+// item that ellipsizes, and the body can shrink past its min-content width, so
+// no row can exceed the column.
+//
+// Chat is filled by writing records straight to the account's PDS, so these
+// flows need no browser login. The harness account's own handle is short, so the
+// long handle needs its own author: the local PDS accepts a second account
+// because handles under its service domain need no DNS.
+const SERVER_URL = process.env.SERVER_URL;
+const PDS_URL = process.env.PDS_HTTPS_URL;
+const HANDLE = process.env.ACCOUNT_HANDLE!;
+const DID = process.env.ACCOUNT_DID!;
+const PASSWORD = process.env.ACCOUNT_PASSWORD!;
+
+test.skip(!PDS_URL, "harness started without its HTTPS hostnames");
+
+// The PDS allows one label of at most 18 characters under the service domain,
+// so a handle this long is as long as the harness can make one.
+const LONG_LABEL = "verylonghandlehere";
+
+async function postChatMessage(
+ pds: APIRequestContext,
+ jwt: string,
+ repo: string,
+ text: string,
+) {
+ const res = await pds.post(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ data: {
+ repo,
+ collection: "place.stream.chat.message",
+ record: {
+ $type: "place.stream.chat.message",
+ text,
+ createdAt: new Date().toISOString(),
+ streamer: DID,
+ },
+ },
+ });
+ expect(res.ok(), `createRecord: ${res.status()} ${await res.text()}`).toBe(
+ true,
+ );
+ const { uri } = await res.json();
+ return { rkey: String(uri).split("/").pop()! };
+}
+
+async function deleteChatMessage(
+ pds: APIRequestContext,
+ jwt: string,
+ repo: string,
+ rkey: string,
+) {
+ const res = await pds.post(`${PDS_URL}/xrpc/com.atproto.repo.deleteRecord`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ data: { repo, collection: "place.stream.chat.message", rkey },
+ });
+ expect(res.ok(), `deleteRecord: ${res.status()} ${await res.text()}`).toBe(
+ true,
+ );
+}
+
+// An account whose handle is one long, unbroken label, with one chat message
+// posted from it.
+//
+// The harness node and its chat list last for the whole run, and the other chat
+// flows locate rows by the harness account's handle, so a message from another
+// author has to be deleted again when this flow is done.
+async function seedLongHandleMessage(text: string) {
+ // the PDS's throwaway CA is trusted by the harness's browsers, not by Node
+ const pds = await request.newContext({ ignoreHTTPSErrors: true });
+ const handle = `${LONG_LABEL}.${new URL(PDS_URL!).hostname}`;
+ const created = await pds.post(
+ `${PDS_URL}/xrpc/com.atproto.server.createAccount`,
+ {
+ data: { handle, email: `${LONG_LABEL}@example.com`, password: PASSWORD },
+ },
+ );
+ expect(
+ created.ok(),
+ `createAccount: ${created.status()} ${await created.text()}`,
+ ).toBe(true);
+ const { did, accessJwt } = await created.json();
+ const { rkey } = await postChatMessage(pds, accessJwt, did, text);
+ return {
+ pds,
+ handle,
+ cleanup: () => deleteChatMessage(pds, accessJwt, did, rkey),
+ };
+}
+
+async function seedOwnMessage(text: string) {
+ const pds = await request.newContext({ ignoreHTTPSErrors: true });
+ const session = await pds.post(
+ `${PDS_URL}/xrpc/com.atproto.server.createSession`,
+ { data: { identifier: HANDLE, password: PASSWORD } },
+ );
+ expect(session.ok(), `createSession: ${session.status()}`).toBe(true);
+ const { accessJwt } = await session.json();
+ await postChatMessage(pds, accessJwt, DID, text);
+ return pds;
+}
+
+async function openStreamChat(page: Page, text: string) {
+ await page.goto(`${SERVER_URL}/`);
+ await page.getByTestId("home-stream-card").first().click();
+ await expect(page.getByText(text, { exact: true }).first()).toBeVisible({
+ timeout: 30_000,
+ });
+ return page.getByTestId("chat-list").first();
+}
+
+// The chat list clips horizontal overflow, so any row wider than it is
+// unreachable. Every row must fit.
+async function expectNoHorizontalOverflow(page: Page) {
+ const { scrollWidth, clientWidth } = await page
+ .getByTestId("chat-list")
+ .first()
+ .evaluate((el) => ({
+ scrollWidth: el.scrollWidth,
+ clientWidth: el.clientWidth,
+ }));
+ expect(
+ scrollWidth,
+ "chat rows stay inside the chat column",
+ ).toBeLessThanOrEqual(clientWidth + 1);
+}
+
+test("chat-long-handle: a long handle ellipsizes instead of pushing the message out of view", async ({
+ page,
+}) => {
+ const message = `long handle body ${Date.now()}`;
+ const author = await seedLongHandleMessage(message);
+
+ try {
+ const list = await openStreamChat(page, message);
+ await expectNoHorizontalOverflow(page);
+
+ // The chat list carries every message seeded earlier in the run, so scope
+ // to this author's own row.
+ const trigger = list
+ .getByTestId(`chat-profile-trigger-${author.handle}`)
+ .first();
+ const handle = trigger.getByText(`@${author.handle}`, { exact: true });
+ const colon = trigger.getByText(":", { exact: true });
+ const body = list.getByText(message, { exact: true }).first();
+ const [handleBox, colonBox, bodyBox, listBox] = await Promise.all([
+ handle.boundingBox(),
+ colon.boundingBox(),
+ body.boundingBox(),
+ list.boundingBox(),
+ ]);
+ expect(handleBox).not.toBeNull();
+ expect(colonBox).not.toBeNull();
+ expect(bodyBox).not.toBeNull();
+ expect(listBox).not.toBeNull();
+
+ // The handle is clamped to one line with an ellipsis: it is wider than the
+ // box it is drawn in, and that box stays inside the column.
+ expect(
+ await handle.evaluate((el) => getComputedStyle(el).textOverflow),
+ "the handle ellipsizes",
+ ).toBe("ellipsis");
+ expect(
+ await handle.evaluate((el) => el.scrollWidth > el.clientWidth),
+ "the long handle is wider than its clamped box",
+ ).toBe(true);
+ expect(handleBox!.x + handleBox!.width).toBeLessThanOrEqual(
+ listBox!.x + listBox!.width + 1,
+ );
+
+ // The colon still rides at the end of the handle, on the handle's line, and
+ // the message body is still visible inside the column.
+ expect(
+ Math.abs(colonBox!.y - handleBox!.y),
+ "colon shares the handle's line",
+ ).toBeLessThan(handleBox!.height);
+ expect(colonBox!.x).toBeGreaterThanOrEqual(
+ handleBox!.x + handleBox!.width - 2,
+ );
+ expect(bodyBox!.x + bodyBox!.width).toBeLessThanOrEqual(
+ listBox!.x + listBox!.width + 1,
+ );
+ } finally {
+ await author.cleanup();
+ await author.pds.dispose();
+ }
+});
+
+test("chat-long-handle: an unbreakable word in the body stays inside the column", async ({
+ page,
+}) => {
+ // Longer on its own than the chat column, like a long URL.
+ const token = "supercalifragilisticexpialidociousandthensomemoreletters";
+ const message = `body with ${token} inside`;
+ const pds = await seedOwnMessage(message);
+
+ const list = await openStreamChat(page, message);
+ await expectNoHorizontalOverflow(page);
+
+ // The word wraps mid-token rather than running past the column: every line
+ // box of the message ends inside the list.
+ const listBox = (await list.boundingBox())!;
+ const listRight = listBox.x + listBox.width;
+ const lineRights = await list
+ .getByText(message, { exact: true })
+ .first()
+ .evaluate((el) =>
+ Array.from(el.getClientRects()).map((rect) => rect.x + rect.width),
+ );
+ expect(lineRights.length).toBeGreaterThan(1);
+ for (const right of lineRights) {
+ expect(right).toBeLessThanOrEqual(listRight + 1);
+ }
+
+ await pds.dispose();
+});
--
2.54.0
From 5ec3e588de34f5f49549ccf830c60f730bd824d0 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 11 Oct 2026 16:23:18 -0700
Subject: [PATCH 11/76] chat-long-handle: give each attempt its own seeded
author
The flow registers its long-handle author on the harness PDS, and a retry
reuses that PDS: the account from the first attempt was still registered, so
the retry failed on `Handle already taken` before it reached the browser.
Derive the label from the clock, which stays inside the PDS's 18-character
service-domain cap and still outgrows the chat column.
Verified with `hack/e2e-web-local.sh chat-long-handle.spec.ts --repeat-each=2`
against one harness: all four runs pass.
---
js/e2e-web/flows/chat-long-handle.spec.ts | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/js/e2e-web/flows/chat-long-handle.spec.ts b/js/e2e-web/flows/chat-long-handle.spec.ts
index 6e24e1c2b..f14ea8f91 100644
--- a/js/e2e-web/flows/chat-long-handle.spec.ts
+++ b/js/e2e-web/flows/chat-long-handle.spec.ts
@@ -27,9 +27,13 @@ const PASSWORD = process.env.ACCOUNT_PASSWORD!;
test.skip(!PDS_URL, "harness started without its HTTPS hostnames");
-// The PDS allows one label of at most 18 characters under the service domain,
-// so a handle this long is as long as the harness can make one.
-const LONG_LABEL = "verylonghandlehere";
+// The account's label under the PDS's service domain. It has to be long enough
+// that the handle outgrows the chat column, and unique per attempt: a retry
+// runs against the same PDS, where the account the previous attempt created is
+// still registered. The PDS caps a service-domain label at 18 characters.
+function longHandleLabel(): string {
+ return `longhandle${Date.now().toString(36).slice(-6)}`;
+}
async function postChatMessage(
pds: APIRequestContext,
@@ -81,11 +85,12 @@ async function deleteChatMessage(
async function seedLongHandleMessage(text: string) {
// the PDS's throwaway CA is trusted by the harness's browsers, not by Node
const pds = await request.newContext({ ignoreHTTPSErrors: true });
- const handle = `${LONG_LABEL}.${new URL(PDS_URL!).hostname}`;
+ const label = longHandleLabel();
+ const handle = `${label}.${new URL(PDS_URL!).hostname}`;
const created = await pds.post(
`${PDS_URL}/xrpc/com.atproto.server.createAccount`,
{
- data: { handle, email: `${LONG_LABEL}@example.com`, password: PASSWORD },
+ data: { handle, email: `${label}@example.com`, password: PASSWORD },
},
);
expect(
--
2.54.0
From 65ea65f894642ad0e23bd28d54933fedf8e92afd Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 13 Oct 2026 10:07:05 -0700
Subject: [PATCH 12/76] fix media sample ownership in concat writer
---
docs/go-conventions.md | 8 ++++++++
pkg/media/io_helpers.go | 6 ++++++
pkg/media/io_helpers_test.go | 23 +++++++++++++++++++++++
3 files changed, 37 insertions(+)
diff --git a/docs/go-conventions.md b/docs/go-conventions.md
index 70ee02e72..cb5d81a82 100644
--- a/docs/go-conventions.md
+++ b/docs/go-conventions.md
@@ -97,3 +97,11 @@ contexts before returning. The leak helper waits for finalizer passes before
requesting the native snapshot; keep that barrier synchronous and never block the
finalizer queue, which also releases Go-owned GStreamer references. Verify changes
to the helper with race-enabled media tests without disabling leak assertions.
+
+`WriterNewSample` releases each appsink sample when its callback returns, after
+unmapping its buffer. The go-gst v1.4 `GstSample` wrapper contains only a pointer
+to an opaque C type; on amd64 it is an 8-byte, GC-pointer-free allocation. Go's
+tiny allocator can pack it beside another live object, preventing its finalizer
+from running even after repeated GC. Clear the binding's finalizer before
+explicitly releasing that transferred reference, or it may be released twice.
+This is sample ownership, not a requirement to wrap every `Buffer.Map` call.
diff --git a/pkg/media/io_helpers.go b/pkg/media/io_helpers.go
index 886f58f97..357827c06 100644
--- a/pkg/media/io_helpers.go
+++ b/pkg/media/io_helpers.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
+ "runtime"
"github.com/go-gst/go-gst/gst"
"github.com/go-gst/go-gst/gst/app"
@@ -86,6 +87,11 @@ func WriterNewSample(ctx context.Context, output io.Writer) func(sink *app.Sink)
if sample == nil {
return gst.FlowOK
}
+ // GstSample is an opaque C type, so this small wrapper can share a
+ // Go tiny-allocation block with a live object and never be finalized.
+ // Release our owned reference when the callback finishes instead.
+ runtime.SetFinalizer(sample, nil)
+ defer sample.Unref()
// Retrieve the buffer from the sample.
buffer := sample.GetBuffer()
diff --git a/pkg/media/io_helpers_test.go b/pkg/media/io_helpers_test.go
index 027fda13c..4b5aac004 100644
--- a/pkg/media/io_helpers_test.go
+++ b/pkg/media/io_helpers_test.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
+ "runtime"
"testing"
"github.com/go-gst/go-gst/gst"
@@ -42,6 +43,28 @@ func TestWriterNewSample(t *testing.T) {
})
}
+// A live tiny allocation can pin a neighboring GstSample wrapper indefinitely.
+// Keep the writer's markers alive through the native leak check, not just EOS.
+func TestWriterNewSampleTinyAllocations(t *testing.T) {
+ writer := &tinyAllocWriter{}
+ withNoGSTLeaks(t, func() {
+ require.NoError(t, writerNewSampleInner(context.Background(), 0, writer))
+ })
+ runtime.KeepAlive(writer)
+}
+
+type tinyAllocWriter struct {
+ bytes.Buffer
+ markers []*uint64
+}
+
+func (w *tinyAllocWriter) Write(p []byte) (int, error) {
+ marker := new(uint64)
+ *marker = uint64(len(p))
+ w.markers = append(w.markers, marker)
+ return w.Buffer.Write(p)
+}
+
func writerNewSampleInner(ctx context.Context, i int, w io.Writer) error {
ctx = log.WithLogValues(ctx, "func", "TestWriterNewSample")
ctx, cancel := context.WithCancel(ctx)
--
2.54.0
From 156ea94cb1b2b6ba92350904a88b4aad2a4ac6e8 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 13 Oct 2026 14:11:22 -0700
Subject: [PATCH 13/76] golive: ignore canonicalURLs from old handles
---
.../components/live-dashboard/livestream-panel.tsx | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx
index 89bd8d9e1..96016e664 100644
--- a/js/app/components/live-dashboard/livestream-panel.tsx
+++ b/js/app/components/live-dashboard/livestream-panel.tsx
@@ -63,6 +63,14 @@ const { flex, p, px, py, gap, layout, bg, borders, text, r, w, typography } =
const isWeb = Platform.OS === "web";
+function isSameNodeUrl(candidate: string, nodeUrl: string): boolean {
+ try {
+ return new URL(candidate).origin === new URL(nodeUrl).origin;
+ } catch {
+ return false;
+ }
+}
+
export const LANG_TAG_PREFIX = "lang:";
export const LANGUAGES = [
@@ -368,7 +376,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) {
if (
livestream.record.canonicalUrl &&
- livestream.record.canonicalUrl !== defaultCanonicalUrl
+ !isSameNodeUrl(livestream.record.canonicalUrl, url)
) {
setCanonicalUrl(livestream.record.canonicalUrl);
}
@@ -384,7 +392,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) {
setCreatePost(typeof livestream.record.post !== "undefined");
}
- }, [livestream, defaultCanonicalUrl]);
+ }, [livestream, url]);
const handleModeChange = useCallback(
(newMode: "create" | "metadata" | "moderation") => {
@@ -791,6 +799,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) {
onChange={(value) => setCanonicalUrl(value)}
placeholder={defaultCanonicalUrl}
variant="filled"
+ testID="live-canonical-url"
inputStyle={[
p[3],
r.md,
--
2.54.0
From 6c33747df29c228848850c26a56ebd4783e63b8b Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 9 Oct 2026 16:19:40 -0700
Subject: [PATCH 14/76] Split secondary app screens and optional playback
dependencies
---
.maestro/logged-out/lazy-navigation.yaml | 36 +++
js/app/README.md | 34 +++
js/app/package.json | 2 +
js/app/src/lazy-screen.tsx | 27 ++
js/app/src/router.tsx | 2 +-
js/app/src/screens/home.tsx | 7 +-
js/app/src/shell.tsx | 167 ++++++++---
js/app/store/slices/blueskySlice.ts | 3 +-
.../src/components/mobile-player/video.tsx | 68 +++--
.../src/livestream-store/stream-key.tsx | 3 +-
js/e2e-web/flows/lazy-navigation.spec.ts | 64 ++++
pnpm-lock.yaml | 281 +++++-------------
12 files changed, 419 insertions(+), 275 deletions(-)
create mode 100644 .maestro/logged-out/lazy-navigation.yaml
create mode 100644 js/app/src/lazy-screen.tsx
create mode 100644 js/e2e-web/flows/lazy-navigation.spec.ts
diff --git a/.maestro/logged-out/lazy-navigation.yaml b/.maestro/logged-out/lazy-navigation.yaml
new file mode 100644
index 000000000..6d9bdabeb
--- /dev/null
+++ b/.maestro/logged-out/lazy-navigation.yaml
@@ -0,0 +1,36 @@
+appId: ${APP_ID}
+---
+# iOS and Android keep dynamic imports in their installed bundle. Exercise
+# first-use category loading and cold deep links, not web-only chunk requests.
+- launchApp
+- openLink: ${APP_ID}://
+- extendedWaitUntil:
+ visible:
+ id: "home-stream-card"
+ timeout: 30000
+- tapOn:
+ text: "Settings"
+- extendedWaitUntil:
+ visible:
+ text: "Advanced"
+ timeout: 30000
+- tapOn:
+ text: "Advanced"
+- extendedWaitUntil:
+ visible:
+ id: "settings-use-custom-node"
+ timeout: 30000
+- takeScreenshot: lazy-navigation-advanced
+- stopApp
+- openLink: ${APP_ID}://settings/advanced
+- extendedWaitUntil:
+ visible:
+ id: "settings-use-custom-node"
+ timeout: 30000
+- takeScreenshot: lazy-navigation-deep-link
+- tapOn:
+ text: "Home"
+- extendedWaitUntil:
+ visible:
+ id: "home-stream-card"
+ timeout: 30000
diff --git a/js/app/README.md b/js/app/README.md
index 0d965c369..ef38c1636 100644
--- a/js/app/README.md
+++ b/js/app/README.md
@@ -38,6 +38,40 @@ Only when native code changes:
Then run `pnpm app ios` (or `pnpm app android`). Bump `runtimeVersion` in
`package.json` only when native dependencies change (it gates expo-updates).
+## Web bundle splitting and Atlas
+
+Secondary screens use module-scope `lazyScreen` imports with a screen-local
+Suspense boundary. Keep the home feed and navigation shell eager; use direct
+imports instead of barrels that re-export deferred screens. The broadcaster
+front door also loads its stream/video screen lazily. HLS playback and stream-key
+generation load their libraries only when needed.
+
+Expo splits production **web** exports. iOS/Android still include these modules
+in the installed bundle; the same loading boundaries work without downloading
+web chunks.
+
+Run the production analyzer from the builder container:
+
+```sh
+pnpm app analyze:web
+```
+
+Open the URL printed by Expo Atlas (in a browser that can reach the container).
+The export is in `.expo/atlas-web`, leaving the embedded `dist` bundle untouched.
+Atlas's module graph includes deferred modules too: measure initial download size
+by summing **all** scripts referenced by `.expo/atlas-web/index.html`, including
+Expo's common/runtime chunks, rather than just the entrypoint. Check chunk source
+maps and browser network requests to confirm heavy modules remain deferred.
+
+`.expo/atlas.jsonl` contains source code and inlined public environment variables;
+keep it local. Rebuild with `make app` and `make dev` before exercising embedded
+UI with `hack/e2e-web-local.sh`.
+
+`lazy-navigation.spec.ts` checks deferred settings requests, navigation away from
+a pending chunk, and a cold settings deep link. The platform-neutral
+`.maestro/logged-out/lazy-navigation.yaml` covers first-use navigation and deep
+links on iOS/Android.
+
## i18n
FTL strings live in `../i18n/locales` and are compiled before use. Edits won't
diff --git a/js/app/package.json b/js/app/package.json
index d00f55aa4..aea2f3ee7 100644
--- a/js/app/package.json
+++ b/js/app/package.json
@@ -19,6 +19,7 @@
"build": "pnpm run build:web && pnpm run prebuild",
"build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json",
"export": "expo export --dump-sourcemap || expo export --dump-sourcemap",
+ "analyze:web": "EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open",
"check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'",
"prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node",
"prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/",
@@ -149,6 +150,7 @@
"@types/sdp-transform": "^2.15.0",
"@types/uuid": "^10.0.0",
"emojibase-data": "^17.0.0",
+ "expo-atlas": "^0.4.3",
"i18next-parser": "^9.0.1",
"typescript": "~5.9.3"
},
diff --git a/js/app/src/lazy-screen.tsx b/js/app/src/lazy-screen.tsx
new file mode 100644
index 000000000..5479ba7e3
--- /dev/null
+++ b/js/app/src/lazy-screen.tsx
@@ -0,0 +1,27 @@
+import { Loader, zero } from "@streamplace/components";
+import type { ComponentProps, ComponentType } from "react";
+import { lazy, Suspense } from "react";
+import { View } from "react-native";
+
+function ScreenFallback() {
+ return (
+
+
+
+ );
+}
+
+// Code-split a screen: its module loads on first render, behind a Suspense
+// boundary local to the screen so the surrounding navigation stays usable.
+export function lazyScreen>(
+ load: () => Promise<{ default: T }>,
+) {
+ const Screen = lazy(load);
+ return function LazyScreen(props: ComponentProps) {
+ return (
+ }>
+
+
+ );
+ };
+}
diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx
index 20ca8fa1b..235e99068 100644
--- a/js/app/src/router.tsx
+++ b/js/app/src/router.tsx
@@ -14,7 +14,7 @@ import {
useTheme,
} from "@streamplace/components";
import { statusColors } from "@streamplace/components/src/lib/theme/tokens";
-import { Provider } from "components";
+import Provider from "components/provider/provider";
import { ImageBackground } from "expo-image";
import { useLiveUser } from "hooks/useLiveUser";
import {
diff --git a/js/app/src/screens/home.tsx b/js/app/src/screens/home.tsx
index cafebcff1..ad07534f3 100644
--- a/js/app/src/screens/home.tsx
+++ b/js/app/src/screens/home.tsx
@@ -23,10 +23,13 @@ import { useFrontDoorReload } from "hooks/useDefaultStreamerReload";
import { useEffect, useState } from "react";
import { Platform, View, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { lazyScreen } from "src/lazy-screen";
import { useStore } from "store";
import { place } from "streamplace";
-import MobileStream from "./mobile-stream";
-import VideoScreen from "./video";
+
+// Only rendered when the node sets a default streamer/video for its front door.
+const MobileStream = lazyScreen(() => import("./mobile-stream"));
+const VideoScreen = lazyScreen(() => import("./video"));
function getStreamActivity(
record: place.stream.livestream.Main,
diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx
index 3fa4b9aeb..1fb377b34 100644
--- a/js/app/src/shell.tsx
+++ b/js/app/src/shell.tsx
@@ -20,29 +20,11 @@ import {
zero,
} from "@streamplace/components";
import { colors, spacing } from "@streamplace/components/src/lib/theme/tokens";
-import { Settings } from "components";
import { SiteTitleLockup, useNodeTitle } from "components/brand/logo";
import { LogoBrandMenu } from "components/brand/logo-brand-menu";
-import Login from "components/login/login";
import LoginModal from "components/login/login-modal";
import PdsHostSelectorModal from "components/login/pds-host-selector-modal";
import { MobileAppBanner } from "components/mobile-app-banner";
-import { AboutCategorySettings } from "components/settings/about-category-settings";
-import { AccountCategorySettings } from "components/settings/account-category-settings";
-import { AdvancedCategorySettings } from "components/settings/advanced-category-settings";
-import { BackupSettings } from "components/settings/backup-settings";
-import { BadgeIssuerPanel } from "components/settings/badge-issuer-panel";
-import { BadgeSelectionManager } from "components/settings/badge-selection-manager";
-import { BrandingAdmin } from "components/settings/branding-admin";
-import { DanmuCategorySettings } from "components/settings/danmu-category-settings";
-import KeyManager from "components/settings/key-manager";
-import { LanguagesCategorySettings } from "components/settings/languages-category-settings";
-import MultistreamManager from "components/settings/multistream-manager";
-import { NotificationsCategorySettings } from "components/settings/notifications-category-settings";
-import { PrivacyCategorySettings } from "components/settings/privacy-category-settings";
-import RecommendationsManager from "components/settings/recommendations-manager";
-import { StreamingCategorySettings } from "components/settings/streaming-category-settings";
-import WebhookManager from "components/settings/webhook-manager";
import {
SidebarOverlay,
SidebarToggle,
@@ -56,34 +38,9 @@ import { useEffect, useState } from "react";
import { Platform, Pressable, StatusBar, View } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { SFSymbols7_0 } from "sf-symbols-typescript";
+import { lazyScreen } from "src/lazy-screen";
import "src/navigation-types";
-import AppReturnScreen from "src/screens/app-return";
-import PopoutChat from "src/screens/chat-popout";
-import DanmuOBSScreen from "src/screens/danmu-obs";
-import DownloadScreen from "src/screens/download";
-import EmbedScreen from "src/screens/embed";
import HomeScreen from "src/screens/home";
-import InfoWidgetEmbed from "src/screens/info-widget-embed";
-import LaunchGoLive from "src/screens/launch-go-live";
-import LiveDashboard from "src/screens/live-dashboard";
-import MobileGoLive from "src/screens/mobile-go-live";
-import MobileStream from "src/screens/mobile-stream";
-import MultiScreen from "src/screens/multi";
-import PopoutInfoWidget from "src/screens/popout-info-widget";
-import PopoutLivestream from "src/screens/popout-livestream";
-import PopoutMultistream from "src/screens/popout-multistream";
-import PopoutStreamMonitor from "src/screens/popout-stream-monitor";
-import SupportScreen from "src/screens/support";
-import UploadScreen, {
- UploadDraftsScreen,
- UploadLivestreamsScreen,
- UploadVideoScreen,
- UploadVideosScreen,
-} from "src/screens/upload";
-import VideoScreen from "src/screens/video";
-import VideoListScreen from "src/screens/video-list";
-import VodScreen from "src/screens/vod";
-import VodEmbedScreen from "src/screens/vod-embed";
import { useStore } from "store";
import {
useHydrated,
@@ -97,6 +54,128 @@ import {
UploadButton,
} from "./router";
+// Every screen except the home feed is code-split, so its module (and
+// dependencies) only load when the route is first visited.
+const Login = lazyScreen(() => import("components/login/login"));
+const Settings = lazyScreen(() =>
+ import("components/settings/settings").then((m) => ({ default: m.Settings })),
+);
+const AboutCategorySettings = lazyScreen(() =>
+ import("components/settings/about-category-settings").then((m) => ({
+ default: m.AboutCategorySettings,
+ })),
+);
+const AccountCategorySettings = lazyScreen(() =>
+ import("components/settings/account-category-settings").then((m) => ({
+ default: m.AccountCategorySettings,
+ })),
+);
+const AdvancedCategorySettings = lazyScreen(() =>
+ import("components/settings/advanced-category-settings").then((m) => ({
+ default: m.AdvancedCategorySettings,
+ })),
+);
+const BackupSettings = lazyScreen(() =>
+ import("components/settings/backup-settings").then((m) => ({
+ default: m.BackupSettings,
+ })),
+);
+const BadgeIssuerPanel = lazyScreen(() =>
+ import("components/settings/badge-issuer-panel").then((m) => ({
+ default: m.BadgeIssuerPanel,
+ })),
+);
+const BadgeSelectionManager = lazyScreen(() =>
+ import("components/settings/badge-selection-manager").then((m) => ({
+ default: m.BadgeSelectionManager,
+ })),
+);
+const BrandingAdmin = lazyScreen(() =>
+ import("components/settings/branding-admin").then((m) => ({
+ default: m.BrandingAdmin,
+ })),
+);
+const DanmuCategorySettings = lazyScreen(() =>
+ import("components/settings/danmu-category-settings").then((m) => ({
+ default: m.DanmuCategorySettings,
+ })),
+);
+const KeyManager = lazyScreen(() => import("components/settings/key-manager"));
+const LanguagesCategorySettings = lazyScreen(() =>
+ import("components/settings/languages-category-settings").then((m) => ({
+ default: m.LanguagesCategorySettings,
+ })),
+);
+const MultistreamManager = lazyScreen(
+ () => import("components/settings/multistream-manager"),
+);
+const NotificationsCategorySettings = lazyScreen(() =>
+ import("components/settings/notifications-category-settings").then((m) => ({
+ default: m.NotificationsCategorySettings,
+ })),
+);
+const PrivacyCategorySettings = lazyScreen(() =>
+ import("components/settings/privacy-category-settings").then((m) => ({
+ default: m.PrivacyCategorySettings,
+ })),
+);
+const RecommendationsManager = lazyScreen(
+ () => import("components/settings/recommendations-manager"),
+);
+const StreamingCategorySettings = lazyScreen(() =>
+ import("components/settings/streaming-category-settings").then((m) => ({
+ default: m.StreamingCategorySettings,
+ })),
+);
+const WebhookManager = lazyScreen(
+ () => import("components/settings/webhook-manager"),
+);
+const AppReturnScreen = lazyScreen(() => import("src/screens/app-return"));
+const PopoutChat = lazyScreen(() => import("src/screens/chat-popout"));
+const DanmuOBSScreen = lazyScreen(() => import("src/screens/danmu-obs"));
+const DownloadScreen = lazyScreen(() => import("src/screens/download"));
+const EmbedScreen = lazyScreen(() => import("src/screens/embed"));
+const InfoWidgetEmbed = lazyScreen(
+ () => import("src/screens/info-widget-embed"),
+);
+const LaunchGoLive = lazyScreen(() => import("src/screens/launch-go-live"));
+const LiveDashboard = lazyScreen(() => import("src/screens/live-dashboard"));
+const MobileGoLive = lazyScreen(() => import("src/screens/mobile-go-live"));
+const MobileStream = lazyScreen(() => import("src/screens/mobile-stream"));
+const MultiScreen = lazyScreen(() => import("src/screens/multi"));
+const PopoutInfoWidget = lazyScreen(
+ () => import("src/screens/popout-info-widget"),
+);
+const PopoutLivestream = lazyScreen(
+ () => import("src/screens/popout-livestream"),
+);
+const PopoutMultistream = lazyScreen(
+ () => import("src/screens/popout-multistream"),
+);
+const PopoutStreamMonitor = lazyScreen(
+ () => import("src/screens/popout-stream-monitor"),
+);
+const SupportScreen = lazyScreen(() => import("src/screens/support"));
+const UploadScreen = lazyScreen(() => import("src/screens/upload"));
+const UploadVideoScreen = lazyScreen(() =>
+ import("src/screens/upload").then((m) => ({ default: m.UploadVideoScreen })),
+);
+const UploadDraftsScreen = lazyScreen(() =>
+ import("src/screens/upload").then((m) => ({ default: m.UploadDraftsScreen })),
+);
+const UploadLivestreamsScreen = lazyScreen(() =>
+ import("src/screens/upload").then((m) => ({
+ default: m.UploadLivestreamsScreen,
+ })),
+);
+const UploadVideosScreen = lazyScreen(() =>
+ import("src/screens/upload").then((m) => ({ default: m.UploadVideosScreen })),
+);
+const VideoScreen = lazyScreen(() => import("src/screens/video"));
+const VideoListScreen = lazyScreen(() => import("src/screens/video-list"));
+const VodScreen = lazyScreen(() => import("src/screens/vod"));
+const VodEmbedScreen = lazyScreen(() => import("src/screens/vod-embed"));
+
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const Tab = createBottomTabNavigator();
diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts
index 93cbcbf89..3427fcdaf 100644
--- a/js/app/store/slices/blueskySlice.ts
+++ b/js/app/store/slices/blueskySlice.ts
@@ -14,7 +14,6 @@ import { Platform } from "react-native";
import { AppStore } from "store";
import { place, StreamplaceAgent } from "streamplace";
import clearQueryParams from "utils/clear-query-params";
-import { privateKeyToAccount } from "viem/accounts";
import { StateCreator } from "zustand";
import createOAuthClient from "../../features/bluesky/oauthClient";
import { OAuthClient } from "../../features/bluesky/oauthClientImport";
@@ -639,6 +638,8 @@ export const createBlueskySlice: StateCreator<
const hexKey = Array.from(exportedKey)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
+ // Static import puts key-generation crypto in the initial web bundle.
+ const { privateKeyToAccount } = await import("viem/accounts");
const account = await privateKeyToAccount(`0x${hexKey}`);
const newKey = {
privateKey: multibaseKey,
diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx
index 2c325d906..f194330af 100644
--- a/js/components/src/components/mobile-player/video.tsx
+++ b/js/components/src/components/mobile-player/video.tsx
@@ -1,4 +1,4 @@
-import Hls from "hls.js";
+import type Hls from "hls.js";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import {
IngestMediaSource,
@@ -375,11 +375,17 @@ export function HLSPlayer(props: VideoProps) {
}, [setStatus]);
useEffect(() => {
- if (!localRef.current) {
+ const video = localRef.current;
+ if (!video) {
return;
}
- if (Hls.isSupported()) {
- var hls = new Hls({
+ // hls.js is loaded on demand so it stays out of the main bundle. The
+ // effect may be cleaned up before the import resolves; `cancelled`
+ // stops a late setup, and `cleanup` tears down whatever was started.
+ let cancelled = false;
+ let cleanup: (() => void) | undefined;
+ const startHlsJs = (HlsClass: typeof Hls) => {
+ const hls = new HlsClass({
maxAudioFramesDrift: 20,
// A `?t=` link starts a VOD partway in; hls.js clamps a position
// past the end onto the last fragment.
@@ -390,13 +396,15 @@ export function HLSPlayer(props: VideoProps) {
hlsRef.current = hls;
hls.loadSource(props.url);
try {
- hls.attachMedia(localRef.current);
+ hls.attachMedia(video);
} catch (e) {
- console.error("error on attachMedia");
- hls.stopLoad();
+ console.error("error on attachMedia", e);
+ hls.destroy();
+ hlsRef.current = null;
+ video.dispatchEvent(new Event("error"));
return;
}
- hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ hls.on(HlsClass.Events.MANIFEST_PARSED, () => {
if (!localRef.current) {
return;
}
@@ -416,7 +424,7 @@ export function HLSPlayer(props: VideoProps) {
);
}
});
- hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
+ hls.on(HlsClass.Events.LEVEL_SWITCHED, (_, data) => {
const l = hls.levels[data.level];
if (!l) return;
const name =
@@ -429,9 +437,9 @@ export function HLSPlayer(props: VideoProps) {
// there, so a viewer waiting on the page starts playing when it
// begins — the same "pre-live" wait WebRTC gets from its reconnects.
let retry: ReturnType | null = null;
- hls.on(Hls.Events.ERROR, (_, data) => {
+ hls.on(HlsClass.Events.ERROR, (_, data) => {
if (!data.fatal || hlsRef.current !== hls) return;
- if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
+ if (data.type === HlsClass.ErrorTypes.NETWORK_ERROR) {
if (retry) clearTimeout(retry);
retry = setTimeout(() => {
retry = null;
@@ -439,7 +447,7 @@ export function HLSPlayer(props: VideoProps) {
hls.loadSource(props.url);
hls.startLoad();
}, 2000);
- } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
+ } else if (data.type === HlsClass.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
}
});
@@ -451,10 +459,10 @@ export function HLSPlayer(props: VideoProps) {
setVodLevels([]);
setPlayingVODRendition(null);
};
- } else if (localRef.current.canPlayType("application/vnd.apple.mpegurl")) {
- // Native HLS (iPhone Safari): the element gives up on a 404 too, so
- // re-point it at the playlist until it plays.
- const video = localRef.current;
+ };
+ // Native HLS (iPhone Safari): the element gives up on a 404 too, so
+ // re-point it at the playlist until it plays.
+ const startNative = () => {
video.src = props.url;
// Native HLS has no hls.js config to carry the start position, so
// seek once the element knows how long the media is.
@@ -485,7 +493,33 @@ export function HLSPlayer(props: VideoProps) {
video.removeEventListener("canplay", onCanPlay);
video.removeEventListener("error", onError);
};
- }
+ };
+ const canPlayNative = () =>
+ video.canPlayType("application/vnd.apple.mpegurl") !== "";
+ import("hls.js").then(
+ ({ default: HlsClass }) => {
+ if (cancelled) return;
+ if (HlsClass.isSupported()) {
+ cleanup = startHlsJs(HlsClass);
+ } else if (canPlayNative()) {
+ cleanup = startNative();
+ }
+ },
+ (e) => {
+ if (cancelled) return;
+ console.error("failed to load hls.js", e);
+ if (canPlayNative()) {
+ cleanup = startNative();
+ } else {
+ // Report it the way the element reports its own errors.
+ video.dispatchEvent(new Event("error"));
+ }
+ },
+ );
+ return () => {
+ cancelled = true;
+ cleanup?.();
+ };
}, [props.url, mode, startTime]);
// The quality menu drives hls.js levels: "auto"/"source" leave ABR to
diff --git a/js/components/src/livestream-store/stream-key.tsx b/js/components/src/livestream-store/stream-key.tsx
index c6397f1c3..c3c761158 100644
--- a/js/components/src/livestream-store/stream-key.tsx
+++ b/js/components/src/livestream-store/stream-key.tsx
@@ -7,7 +7,6 @@ import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto";
import { getBrowserName, getPlatform } from "@streamplace/core";
import { useEffect, useState } from "react";
import { place } from "streamplace";
-import { privateKeyToAccount } from "viem/accounts";
import { usePDSAgent } from "../streamplace-store/xrpc";
import { useLivestreamStore } from "./use-store";
@@ -47,6 +46,8 @@ export const useStreamKey = (): {
const hexKey = Array.from(exportedKey)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
+ // Static import puts key-generation crypto in the initial web bundle.
+ const { privateKeyToAccount } = await import("viem/accounts");
const account = privateKeyToAccount(`0x${hexKey}`);
const newKey = {
privateKey: multibaseKey,
diff --git a/js/e2e-web/flows/lazy-navigation.spec.ts b/js/e2e-web/flows/lazy-navigation.spec.ts
new file mode 100644
index 000000000..280ac6e09
--- /dev/null
+++ b/js/e2e-web/flows/lazy-navigation.spec.ts
@@ -0,0 +1,64 @@
+import { expect, test } from "@playwright/test";
+
+// Native counterpart: .maestro/logged-out/lazy-navigation.yaml. Only web
+// exports fetch separate chunks; native imports resolve from the installed bundle.
+test("lazy navigation: defer settings until opened and keep shell usable", async ({
+ page,
+}) => {
+ const scripts: string[] = [];
+ page.on("request", (request) => {
+ if (request.resourceType() === "script") scripts.push(request.url());
+ });
+
+ await page.goto("/");
+ await expect(page.getByTestId("home-stream-card").first()).toBeVisible();
+ expect(scripts.some((url) => /\/settings-[^/]+\.js$/.test(url))).toBe(false);
+ expect(
+ scripts.some((url) => /\/advanced-category-settings-[^/]+\.js$/.test(url)),
+ ).toBe(false);
+
+ let releaseChunk!: () => void;
+ const heldChunk = new Promise((resolve) => {
+ releaseChunk = resolve;
+ });
+ await page.route(/\/settings-[^/]+\.js$/, async (route) => {
+ await heldChunk;
+ await route.continue();
+ });
+
+ try {
+ await page
+ .getByRole("link", { name: "Settings", exact: true })
+ .first()
+ .click();
+ await expect(page.getByLabel("Loading screen")).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Home", exact: true }).first(),
+ ).toBeVisible();
+ // Leaving a pending screen must not leave the entire app suspended.
+ await page.getByRole("link", { name: "Home", exact: true }).first().click();
+ await expect(page.getByTestId("home-stream-card").first()).toBeVisible();
+ } finally {
+ releaseChunk();
+ }
+
+ await page
+ .getByRole("link", { name: "Settings", exact: true })
+ .first()
+ .click();
+ await expect(page.getByText("Advanced", { exact: true })).toBeVisible();
+ expect(scripts.some((url) => /\/settings-[^/]+\.js$/.test(url))).toBe(true);
+ expect(
+ scripts.some((url) => /\/advanced-category-settings-[^/]+\.js$/.test(url)),
+ ).toBe(false);
+
+ await page.getByText("Advanced", { exact: true }).click();
+ await expect(page.getByTestId("settings-use-custom-node")).toBeVisible();
+ expect(
+ scripts.some((url) => /\/advanced-category-settings-[^/]+\.js$/.test(url)),
+ ).toBe(true);
+
+ // A cold deep link must load the same category without visiting Settings first.
+ await page.reload();
+ await expect(page.getByTestId("settings-use-custom-node")).toBeVisible();
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d2e72e1ae..bd0c7b2aa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -399,6 +399,9 @@ importers:
emojibase-data:
specifier: ^17.0.0
version: 17.0.0(emojibase@17.0.0)
+ expo-atlas:
+ specifier: ^0.4.3
+ version: 0.4.3(expo@55.0.6)
i18next-parser:
specifier: ^9.0.1
version: 9.4.0
@@ -3613,6 +3616,9 @@ packages:
'@expo/sdk-runtime-versions@1.0.0':
resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==}
+ '@expo/server@0.5.3':
+ resolution: {integrity: sha512-WXsWzeBs5v/h0PUfHyNLLz07rwwO5myQ1A5DGYewyyGLmsyl61yVCe8AgAlp1wkiMsqhj2hZqI2u3K10QnCMrQ==}
+
'@expo/spawn-async@1.7.2':
resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==}
engines: {node: '>=12'}
@@ -8405,6 +8411,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ basic-auth@2.0.1:
+ resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
+ engines: {node: '>= 0.8'}
+
batch@0.6.1:
resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==}
@@ -8463,10 +8473,6 @@ packages:
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
- body-parser@1.20.3:
- resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
- engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
-
body-parser@1.20.4:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -8604,10 +8610,6 @@ packages:
resolution: {integrity: sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==}
engines: {node: '>=12.17'}
- bytes@3.0.0:
- resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
- engines: {node: '>= 0.8'}
-
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -9072,10 +9074,6 @@ packages:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'}
- compression@1.7.4:
- resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==}
- engines: {node: '>= 0.8.0'}
-
compression@1.8.1:
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
engines: {node: '>= 0.8.0'}
@@ -9154,16 +9152,9 @@ packages:
cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
- cookie-signature@1.0.6:
- resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
-
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
- cookie@0.6.0:
- resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
- engines: {node: '>= 0.6'}
-
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
@@ -10159,6 +10150,12 @@ packages:
react: '*'
react-native: '*'
+ expo-atlas@0.4.3:
+ resolution: {integrity: sha512-nN2bouxFvMsqZqLl0ka+eI9Ofida0PcuoE4v+7fHlgyp95X2cCL8Acf0nRKXmmIBEYazdw0d7BAOZfC0b41oxA==}
+ hasBin: true
+ peerDependencies:
+ expo: '*'
+
expo-build-properties@55.0.9:
resolution: {integrity: sha512-p0rNHW/6ghKsvjlUn2DQfbLYuTB6ba+15SeTPOz5BYbyU1F/0F/YyxBtHdmWitqgDPn6VgXQeKhiNC1fMwYDpg==}
peerDependencies:
@@ -10371,10 +10368,6 @@ packages:
peerDependencies:
express: ^4.0.0 || ^5.0.0-alpha.1
- express@4.21.0:
- resolution: {integrity: sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==}
- engines: {node: '>= 0.10.0'}
-
express@4.22.1:
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'}
@@ -10542,10 +10535,6 @@ packages:
resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
engines: {node: '>= 0.8'}
- finalhandler@1.3.1:
- resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
- engines: {node: '>= 0.8'}
-
finalhandler@1.3.2:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
@@ -10662,6 +10651,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
+ freeport-async@2.0.0:
+ resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==}
+ engines: {node: '>=8'}
+
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@@ -11271,10 +11264,6 @@ packages:
resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==}
engines: {node: '>= 0.6'}
- http-errors@2.0.0:
- resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
- engines: {node: '>= 0.8'}
-
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -13222,6 +13211,10 @@ packages:
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
+ morgan@1.12.1:
+ resolution: {integrity: sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==}
+ engines: {node: '>= 0.8.0'}
+
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -13580,10 +13573,6 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
- on-headers@1.0.2:
- resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
- engines: {node: '>= 0.8'}
-
on-headers@1.1.0:
resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
engines: {node: '>= 0.8'}
@@ -13921,9 +13910,6 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
- path-to-regexp@0.1.10:
- resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==}
-
path-to-regexp@0.1.12:
resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
@@ -14457,10 +14443,6 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
- qs@6.13.0:
- resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
- engines: {node: '>=0.6'}
-
qs@6.14.2:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
@@ -14525,10 +14507,6 @@ packages:
rate-limiter-flexible@2.4.2:
resolution: {integrity: sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw==}
- raw-body@2.5.2:
- resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
- engines: {node: '>= 0.8'}
-
raw-body@2.5.3:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
@@ -15337,10 +15315,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
- send@0.19.0:
- resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
- engines: {node: '>= 0.8.0'}
-
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
@@ -15370,10 +15344,6 @@ packages:
resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==}
engines: {node: '>= 0.8.0'}
- serve-static@1.16.2:
- resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
- engines: {node: '>= 0.8.0'}
-
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
@@ -15459,10 +15429,6 @@ packages:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
- side-channel@1.0.6:
- resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
- engines: {node: '>= 0.4'}
-
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
@@ -15717,10 +15683,6 @@ packages:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
- statuses@2.0.1:
- resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
- engines: {node: '>= 0.8'}
-
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -21554,6 +21516,15 @@ snapshots:
'@expo/sdk-runtime-versions@1.0.0': {}
+ '@expo/server@0.5.3':
+ dependencies:
+ abort-controller: 3.0.0
+ debug: 4.4.3(supports-color@5.5.0)
+ source-map-support: 0.5.21
+ undici: 6.24.0
+ transitivePeerDependencies:
+ - supports-color
+
'@expo/spawn-async@1.7.2':
dependencies:
cross-spawn: 7.0.6
@@ -23675,7 +23646,7 @@ snapshots:
invariant: 2.2.4
nullthrows: 1.1.1
open: 7.4.2
- serve-static: 1.16.2
+ serve-static: 1.16.3
ws: 7.5.10(bufferutil@4.0.8)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- bufferutil
@@ -27268,6 +27239,10 @@ snapshots:
baseline-browser-mapping@2.10.7: {}
+ basic-auth@2.0.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
batch@0.6.1: {}
bcp-47-match@2.0.3: {}
@@ -27331,23 +27306,6 @@ snapshots:
bn.js@4.12.3: {}
- body-parser@1.20.3:
- dependencies:
- bytes: 3.1.2
- content-type: 1.0.5
- debug: 2.6.9
- depd: 2.0.0
- destroy: 1.2.0
- http-errors: 2.0.0
- iconv-lite: 0.4.24
- on-finished: 2.4.1
- qs: 6.13.0
- raw-body: 2.5.2
- type-is: 1.6.18
- unpipe: 1.0.0
- transitivePeerDependencies:
- - supports-color
-
body-parser@1.20.4:
dependencies:
bytes: 3.1.2
@@ -27529,8 +27487,6 @@ snapshots:
byte-size@8.1.1: {}
- bytes@3.0.0: {}
-
bytes@3.1.2: {}
cac@6.7.14: {}
@@ -27994,18 +27950,6 @@ snapshots:
dependencies:
mime-db: 1.54.0
- compression@1.7.4:
- dependencies:
- accepts: 1.3.8
- bytes: 3.0.0
- compressible: 2.0.18
- debug: 2.6.9
- on-headers: 1.0.2
- safe-buffer: 5.1.2
- vary: 1.1.2
- transitivePeerDependencies:
- - supports-color
-
compression@1.8.1:
dependencies:
bytes: 3.1.2
@@ -28108,12 +28052,8 @@ snapshots:
cookie-es@3.1.1: {}
- cookie-signature@1.0.6: {}
-
cookie-signature@1.0.7: {}
- cookie@0.6.0: {}
-
cookie@0.7.2: {}
cookie@1.1.1: {}
@@ -29366,6 +29306,24 @@ snapshots:
- supports-color
- typescript
+ expo-atlas@0.4.3(expo@55.0.6):
+ dependencies:
+ '@expo/server': 0.5.3
+ arg: 5.0.2
+ chalk: 4.1.2
+ compression: 1.8.1
+ connect: 3.7.0
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ express: 4.22.1
+ freeport-async: 2.0.0
+ getenv: 2.0.0
+ morgan: 1.12.1
+ open: 8.4.2
+ serve-static: 1.16.3
+ stream-json: 1.8.0
+ transitivePeerDependencies:
+ - supports-color
+
expo-build-properties@55.0.9(expo@55.0.6):
dependencies:
'@expo/schema-utils': 55.0.2
@@ -29644,42 +29602,6 @@ snapshots:
- bufferutil
- utf-8-validate
- express@4.21.0:
- dependencies:
- accepts: 1.3.8
- array-flatten: 1.1.1
- body-parser: 1.20.3
- content-disposition: 0.5.4
- content-type: 1.0.5
- cookie: 0.6.0
- cookie-signature: 1.0.6
- debug: 2.6.9
- depd: 2.0.0
- encodeurl: 2.0.0
- escape-html: 1.0.3
- etag: 1.8.1
- finalhandler: 1.3.1
- fresh: 0.5.2
- http-errors: 2.0.0
- merge-descriptors: 1.0.3
- methods: 1.1.2
- on-finished: 2.4.1
- parseurl: 1.3.3
- path-to-regexp: 0.1.10
- proxy-addr: 2.0.7
- qs: 6.13.0
- range-parser: 1.2.1
- safe-buffer: 5.2.1
- send: 0.19.0
- serve-static: 1.16.2
- setprototypeof: 1.2.0
- statuses: 2.0.1
- type-is: 1.6.18
- utils-merge: 1.0.1
- vary: 1.1.2
- transitivePeerDependencies:
- - supports-color
-
express@4.22.1:
dependencies:
accepts: 1.3.8
@@ -29894,18 +29816,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- finalhandler@1.3.1:
- dependencies:
- debug: 2.6.9
- encodeurl: 2.0.0
- escape-html: 1.0.3
- on-finished: 2.4.1
- parseurl: 1.3.3
- statuses: 2.0.1
- unpipe: 1.0.0
- transitivePeerDependencies:
- - supports-color
-
finalhandler@1.3.2:
dependencies:
debug: 2.6.9
@@ -30095,6 +30005,8 @@ snapshots:
forwarded@0.2.0: {}
+ freeport-async@2.0.0: {}
+
fresh@0.5.2: {}
frimousse@0.3.0(react@19.2.0)(typescript@5.9.3):
@@ -30965,14 +30877,6 @@ snapshots:
setprototypeof: 1.1.0
statuses: 1.5.0
- http-errors@2.0.0:
- dependencies:
- depd: 2.0.0
- inherits: 2.0.4
- setprototypeof: 1.2.0
- statuses: 2.0.1
- toidentifier: 1.0.1
-
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -31302,7 +31206,7 @@ snapshots:
dependencies:
es-errors: 1.3.0
hasown: 2.0.2
- side-channel: 1.0.6
+ side-channel: 1.1.0
internal-slot@1.1.0:
dependencies:
@@ -32527,7 +32431,7 @@ snapshots:
minipass-fetch: 3.0.5
minipass-flush: 1.0.5
minipass-pipeline: 1.2.4
- negotiator: 0.6.3
+ negotiator: 0.6.4
proc-log: 4.2.0
promise-retry: 2.0.1
ssri: 10.0.6
@@ -33651,6 +33555,16 @@ snapshots:
moment@2.30.1: {}
+ morgan@1.12.1:
+ dependencies:
+ basic-auth: 2.0.1
+ debug: 2.6.9
+ depd: 2.0.0
+ on-finished: 2.4.1
+ on-headers: 1.1.0
+ transitivePeerDependencies:
+ - supports-color
+
mrmime@2.0.1: {}
ms@2.0.0: {}
@@ -34060,8 +33974,6 @@ snapshots:
dependencies:
ee-first: 1.1.1
- on-headers@1.0.2: {}
-
on-headers@1.1.0: {}
once@1.4.0:
@@ -34529,8 +34441,6 @@ snapshots:
lru-cache: 11.2.6
minipass: 7.1.3
- path-to-regexp@0.1.10: {}
-
path-to-regexp@0.1.12: {}
path-to-regexp@6.3.0: {}
@@ -35027,10 +34937,6 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
- qs@6.13.0:
- dependencies:
- side-channel: 1.0.6
-
qs@6.14.2:
dependencies:
side-channel: 1.1.0
@@ -35145,13 +35051,6 @@ snapshots:
rate-limiter-flexible@2.4.2: {}
- raw-body@2.5.2:
- dependencies:
- bytes: 3.1.2
- http-errors: 2.0.0
- iconv-lite: 0.4.24
- unpipe: 1.0.0
-
raw-body@2.5.3:
dependencies:
bytes: 3.1.2
@@ -36223,24 +36122,6 @@ snapshots:
semver@7.7.4: {}
- send@0.19.0:
- dependencies:
- debug: 2.6.9
- depd: 2.0.0
- destroy: 1.2.0
- encodeurl: 1.0.2
- escape-html: 1.0.3
- etag: 1.8.1
- fresh: 0.5.2
- http-errors: 2.0.0
- mime: 1.6.0
- ms: 2.1.3
- on-finished: 2.4.1
- range-parser: 1.2.1
- statuses: 2.0.1
- transitivePeerDependencies:
- - supports-color
-
send@0.19.2:
dependencies:
debug: 2.6.9
@@ -36288,15 +36169,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- serve-static@1.16.2:
- dependencies:
- encodeurl: 2.0.0
- escape-html: 1.0.3
- parseurl: 1.3.3
- send: 0.19.0
- transitivePeerDependencies:
- - supports-color
-
serve-static@1.16.3:
dependencies:
encodeurl: 2.0.0
@@ -36444,13 +36316,6 @@ snapshots:
object-inspect: 1.13.4
side-channel-map: 1.0.1
- side-channel@1.0.6:
- dependencies:
- call-bind: 1.0.8
- es-errors: 1.3.0
- get-intrinsic: 1.3.0
- object-inspect: 1.13.2
-
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -36737,8 +36602,6 @@ snapshots:
statuses@1.5.0: {}
- statuses@2.0.1: {}
-
statuses@2.0.2: {}
std-env@4.1.0: {}
@@ -38152,10 +38015,10 @@ snapshots:
bonjour-service: 1.2.1
chokidar: 3.6.0
colorette: 2.0.20
- compression: 1.7.4
+ compression: 1.8.1
connect-history-api-fallback: 2.0.0
default-gateway: 6.0.3
- express: 4.21.0
+ express: 4.22.1
graceful-fs: 4.2.11
html-entities: 2.5.2
http-proxy-middleware: 2.0.6(@types/express@4.17.21)(debug@4.4.0)
--
2.54.0
From 0fec0858f4dc64279a6a43050ac2915aa65fc453 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 9 Oct 2026 16:36:07 -0700
Subject: [PATCH 15/76] Keep navigation usable after failed lazy screen
downloads
---
js/app/README.md | 11 +++++----
js/app/package.json | 2 +-
js/app/src/lazy-screen.tsx | 30 +++++++++++++++++++-----
js/e2e-web/flows/lazy-navigation.spec.ts | 30 ++++++++++++++++++++++++
4 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/js/app/README.md b/js/app/README.md
index ef38c1636..ce0f80996 100644
--- a/js/app/README.md
+++ b/js/app/README.md
@@ -40,8 +40,10 @@ Then run `pnpm app ios` (or `pnpm app android`). Bump `runtimeVersion` in
## Web bundle splitting and Atlas
-Secondary screens use module-scope `lazyScreen` imports with a screen-local
-Suspense boundary. Keep the home feed and navigation shell eager; use direct
+Secondary screens use module-scope `lazyScreen` imports with screen-local
+Suspense and error boundaries. A failed download leaves navigation usable, and
+retry creates a fresh lazy import instead of reusing React's cached rejection.
+Keep the home feed and navigation shell eager; use direct
imports instead of barrels that re-export deferred screens. The broadcaster
front door also loads its stream/video screen lazily. HLS playback and stream-key
generation load their libraries only when needed.
@@ -56,7 +58,8 @@ Run the production analyzer from the builder container:
pnpm app analyze:web
```
-Open the URL printed by Expo Atlas (in a browser that can reach the container).
+The command prepares generated brand assets before exporting. Open the URL
+printed by Expo Atlas (in a browser that can reach the container).
The export is in `.expo/atlas-web`, leaving the embedded `dist` bundle untouched.
Atlas's module graph includes deferred modules too: measure initial download size
by summing **all** scripts referenced by `.expo/atlas-web/index.html`, including
@@ -68,7 +71,7 @@ keep it local. Rebuild with `make app` and `make dev` before exercising embedded
UI with `hack/e2e-web-local.sh`.
`lazy-navigation.spec.ts` checks deferred settings requests, navigation away from
-a pending chunk, and a cold settings deep link. The platform-neutral
+a pending chunk, recovery from a failed chunk, and a cold settings deep link. The platform-neutral
`.maestro/logged-out/lazy-navigation.yaml` covers first-use navigation and deep
links on iOS/Android.
diff --git a/js/app/package.json b/js/app/package.json
index aea2f3ee7..125a17d85 100644
--- a/js/app/package.json
+++ b/js/app/package.json
@@ -19,7 +19,7 @@
"build": "pnpm run build:web && pnpm run prebuild",
"build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json",
"export": "expo export --dump-sourcemap || expo export --dump-sourcemap",
- "analyze:web": "EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open",
+ "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open",
"check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'",
"prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node",
"prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/",
diff --git a/js/app/src/lazy-screen.tsx b/js/app/src/lazy-screen.tsx
index 5479ba7e3..15e1f6a7b 100644
--- a/js/app/src/lazy-screen.tsx
+++ b/js/app/src/lazy-screen.tsx
@@ -1,6 +1,11 @@
-import { Loader, zero } from "@streamplace/components";
+import {
+ AppCrashScreen,
+ ErrorBoundary,
+ Loader,
+ zero,
+} from "@streamplace/components";
import type { ComponentProps, ComponentType } from "react";
-import { lazy, Suspense } from "react";
+import { lazy, Suspense, useState } from "react";
import { View } from "react-native";
function ScreenFallback() {
@@ -16,12 +21,25 @@ function ScreenFallback() {
export function lazyScreen>(
load: () => Promise<{ default: T }>,
) {
- const Screen = lazy(load);
+ const InitialScreen = lazy(load);
return function LazyScreen(props: ComponentProps) {
+ const [Screen, setScreen] = useState(() => InitialScreen);
return (
- }>
-
-
+ (
+ {
+ // React.lazy caches rejection; a retry needs a fresh lazy component.
+ setScreen(() => lazy(load));
+ reset();
+ }}
+ />
+ )}
+ >
+ }>
+
+
+
);
};
}
diff --git a/js/e2e-web/flows/lazy-navigation.spec.ts b/js/e2e-web/flows/lazy-navigation.spec.ts
index 280ac6e09..61a2819ff 100644
--- a/js/e2e-web/flows/lazy-navigation.spec.ts
+++ b/js/e2e-web/flows/lazy-navigation.spec.ts
@@ -62,3 +62,33 @@ test("lazy navigation: defer settings until opened and keep shell usable", async
await page.reload();
await expect(page.getByTestId("settings-use-custom-node")).toBeVisible();
});
+
+test("lazy navigation: retry a failed chunk without losing navigation", async ({
+ page,
+}) => {
+ let attempts = 0;
+ await page.route(/\/settings-[^/]+\.js$/, async (route) => {
+ attempts++;
+ if (attempts === 1) {
+ await route.fulfill({ status: 503, body: "Temporarily unavailable" });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await page.goto("/");
+ await expect(page.getByTestId("home-stream-card").first()).toBeVisible();
+ await page
+ .getByRole("link", { name: "Settings", exact: true })
+ .first()
+ .click();
+ await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Home", exact: true }).first(),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Try again" }).click();
+ await expect(page.getByText("Advanced", { exact: true })).toBeVisible();
+ expect(attempts).toBe(2);
+ await page.getByRole("link", { name: "Home", exact: true }).first().click();
+ await expect(page.getByTestId("home-stream-card").first()).toBeVisible();
+});
--
2.54.0
From 10d2a2d85ab72741f6f5f083d253ab42efb7cba3 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 9 Oct 2026 17:33:47 -0700
Subject: [PATCH 16/76] Show a bare web bootstrap before loading Expo
application scripts
---
js/app/README.md | 29 +++++++++--
js/app/package.json | 4 +-
js/app/scripts/bootstrap-web.mjs | 81 ++++++++++++++++++++++++++++++
js/app/scripts/web-bootstrap.js | 45 +++++++++++++++++
js/e2e-web/flows/bootstrap.spec.ts | 67 ++++++++++++++++++++++++
js/e2e-web/playwright.config.ts | 6 +--
6 files changed, 223 insertions(+), 9 deletions(-)
create mode 100644 js/app/scripts/bootstrap-web.mjs
create mode 100644 js/app/scripts/web-bootstrap.js
create mode 100644 js/e2e-web/flows/bootstrap.spec.ts
diff --git a/js/app/README.md b/js/app/README.md
index ce0f80996..330af035f 100644
--- a/js/app/README.md
+++ b/js/app/README.md
@@ -52,6 +52,19 @@ Expo splits production **web** exports. iOS/Android still include these modules
in the installed bundle; the same loading boundaries work without downloading
web chunks.
+Production web HTML renders a dependency-free bootstrap spinner before loading
+Expo's scripts. `scripts/bootstrap-web.mjs` runs after normal and Atlas exports:
+it replaces the eager script tags with a hashed, bare DOM bootloader, preserving
+Expo's runtime/common/entry order and asset base URL. React replaces the spinner
+inside `#root` on its first committed render. Failed downloads offer a retry
+without replaying successfully loaded scripts. Styles come from design tokens
+at build time; no React, theme provider, fonts, or app modules load to show it.
+Development Metro and installed iOS/Android entrypoints are unchanged.
+
+This reduces **bytes required for the first loading UI**, not the bytes required
+for a usable Home screen. The app still downloads automatically after that first
+paint; route-level chunks remain deferred until opened.
+
Run the production analyzer from the builder container:
```sh
@@ -61,15 +74,23 @@ pnpm app analyze:web
The command prepares generated brand assets before exporting. Open the URL
printed by Expo Atlas (in a browser that can reach the container).
The export is in `.expo/atlas-web`, leaving the embedded `dist` bundle untouched.
-Atlas's module graph includes deferred modules too: measure initial download size
-by summing **all** scripts referenced by `.expo/atlas-web/index.html`, including
-Expo's common/runtime chunks, rather than just the entrypoint. Check chunk source
-maps and browser network requests to confirm heavy modules remain deferred.
+Atlas's graph still describes the full Expo application, including deferred
+modules; the separately emitted bootstrap is not in that graph. Measure the
+bootstrap by summing the HTML and **all** script URLs directly referenced by
+`.expo/atlas-web/index.html`. The `bootstrap-scripts` JSON manifest lists the
+runtime/common/entry scripts that download after paint: include those too when
+measuring bytes required for Home. Check source maps and browser network requests
+to confirm HLS, stream-key crypto, and secondary screens remain deferred.
`.expo/atlas.jsonl` contains source code and inlined public environment variables;
keep it local. Rebuild with `make app` and `make dev` before exercising embedded
UI with `hack/e2e-web-local.sh`.
+`bootstrap.spec.ts` runs in Chromium and Firefox: it enforces a sub-1-MB first
+loading payload, holds app downloads to check the visible spinner, then checks
+the handoff to Home and recovery from a failed common-chunk request. This
+HTML-only bootstrap does not apply to native installs.
+
`lazy-navigation.spec.ts` checks deferred settings requests, navigation away from
a pending chunk, recovery from a failed chunk, and a cold settings deep link. The platform-neutral
`.maestro/logged-out/lazy-navigation.yaml` covers first-use navigation and deep
diff --git a/js/app/package.json b/js/app/package.json
index 125a17d85..39e740ed8 100644
--- a/js/app/package.json
+++ b/js/app/package.json
@@ -18,8 +18,8 @@
"brand": "pnpm --filter @streamplace/brand generate",
"build": "pnpm run build:web && pnpm run prebuild",
"build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json",
- "export": "expo export --dump-sourcemap || expo export --dump-sourcemap",
- "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open",
+ "export": "(expo export --dump-sourcemap || expo export --dump-sourcemap) && node --experimental-strip-types scripts/bootstrap-web.mjs",
+ "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && node --experimental-strip-types scripts/bootstrap-web.mjs .expo/atlas-web && expo-atlas --no-open",
"check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'",
"prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node",
"prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/",
diff --git a/js/app/scripts/bootstrap-web.mjs b/js/app/scripts/bootstrap-web.mjs
new file mode 100644
index 000000000..1b30f008b
--- /dev/null
+++ b/js/app/scripts/bootstrap-web.mjs
@@ -0,0 +1,81 @@
+import { createHash } from "node:crypto";
+import { readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import {
+ borderRadius,
+ motion,
+ spacing,
+ surfaces,
+ textAlphas,
+ touchTargets,
+ typeScale,
+} from "../../components/src/lib/theme/tokens.ts";
+
+// Expo's shared chunk is eager even when every app screen is lazy. Keep its
+// generated bundles intact, but let a tiny bootloader start them after paint.
+const output = process.argv[2] ?? "dist";
+const htmlPath = path.join(output, "index.html");
+let html = await readFile(htmlPath, "utf8");
+const scripts = [];
+html = html.replace(
+ /
+