Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demo/web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ discovery.run((effect) => {
for (;;) {
const entry = await Promise.race([effect.cancel, announced.next()]);
if (!entry) break;
const path = entry.path;
const path = entry.prefix;
// Only catalog-backed broadcasts are watchable streams; this skips the relay's
// `.stats` broadcast (see the stats dashboard demo for that one).
if (!path.endsWith(".hang") && !path.endsWith(".msf")) continue;
Expand Down
2 changes: 1 addition & 1 deletion demo/web/src/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ discovery.run((effect) => {
for (;;) {
const entry = await Promise.race([effect.cancel, announced.next()]);
if (!entry) break;
const path = entry.path;
const path = entry.prefix;
const node = Net.Path.stripPrefix(prefix, path);
if (!node) continue;

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/rs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ let consumer = origin.consume();
let mut announced = consumer.announced();
while let Some(update) = announced.next().await {
if !update.kind.is_active() { continue }
let broadcast = consumer.request_broadcast(&update.path).await?;
let broadcast = consumer.request_broadcast(&update.prefix).await?;
let catalog = broadcast
.track(hang::Catalog::DEFAULT_NAME)?
.subscribe(hang::Catalog::default_subscription())
Expand Down
5 changes: 3 additions & 2 deletions js/hang/src/catalog/data.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import { compressionSupported } from "./compression.ts";
import { modeSupported } from "./mode.ts";
import type { RelativeBroadcast } from "./path.ts";
import { RootSchema } from "./root.ts";

// The `json` and `binary` sections list application data tracks. Each entry says how to read the
Expand Down Expand Up @@ -28,8 +29,8 @@ test("data tracks parse with their mode and compression", () => {
expect(parsed.json?.tracks.chat?.schema).toBe("https://example.com/chat.schema.json");

expect(parsed.json?.tracks.status?.mode).toBe("snapshot");
// Normalized like Rust PathRelative, the same as a media rendition's reference.
expect(parsed.json?.tracks.status?.broadcast).toBe("source");
// Normalized like Rust path::Relative, the same as a media rendition's reference.
expect(parsed.json?.tracks.status?.broadcast).toBe("source" as RelativeBroadcast);
expect(parsed.json?.tracks.status?.compression).toBeUndefined();

expect(parsed.binary?.tracks.thumbnail?.mode).toBe("snapshot");
Expand Down
14 changes: 10 additions & 4 deletions js/hang/src/catalog/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import * as z from "@zod/mini";
/**
* Zod schema for a relative broadcast reference stored in a catalog (a rendition's
* `broadcast` field, e.g. "./source"). Normalizes the input the same way the Rust
* `PathRelative` type does so JS and Rust agree byte-for-byte after deserialization.
* `path::Relative` type does so JS and Rust agree byte-for-byte after deserialization.
* Resolve it against the catalog broadcast's own path with `Path.tryResolve`, which returns
* `undefined` for a reference that walks above the root: hang requires rejecting such a catalog.
*/
export const RelativeBroadcastSchema = z.pipe(z.string(), z.transform(Path.normalizeRelative));
export const RelativeBroadcastSchema: z.ZodMiniType<RelativeBroadcast, string> = z.pipe(
z.string(),
z.transform(Path.normalizeRelative),
);

/** A normalized relative broadcast reference. */
export type RelativeBroadcast = z.infer<typeof RelativeBroadcastSchema>;
/**
* A normalized relative broadcast reference: the same brand as `Path.Relative`, spelled
* out here so the catalog schemas' inferred types stay nameable from this package.
*/
export type RelativeBroadcast = string & { __brand: "Relative" };
7 changes: 4 additions & 3 deletions js/hang/src/catalog/root.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import * as z from "@zod/mini";
import { ARCHIVE_VERSION } from "./archive.ts";
import type { RelativeBroadcast } from "./path.ts";
import { RootSchema } from "./root.ts";

// The base catalog carries the media sections (`video`/`audio`) and the data track sections
Expand Down Expand Up @@ -38,8 +39,8 @@ test("rendition broadcast reference is parsed and normalized", () => {
};
const parsed = RootSchema.parse(catalog);
if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section");
// Normalized like Rust PathRelative: redundant `.` and empty segments are dropped.
expect(parsed.video.renditions.video?.broadcast).toBe("source");
// Normalized like Rust path::Relative: redundant `.` and empty segments are dropped.
expect(parsed.video.renditions.video?.broadcast).toBe("source" as RelativeBroadcast);
});

test("rendition parent broadcast reference stays distinct from empty", () => {
Expand All @@ -56,7 +57,7 @@ test("rendition parent broadcast reference stays distinct from empty", () => {
};
const parsed = RootSchema.parse(catalog);
if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section");
expect(parsed.video.renditions.video?.broadcast).toBe(".");
expect(parsed.video.renditions.video?.broadcast).toBe("." as RelativeBroadcast);
});

test("rendition without broadcast reference stays undefined", () => {
Expand Down
4 changes: 2 additions & 2 deletions js/net/examples/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ async function main() {
// Discover broadcasts announced by the server
for await (const announcement of announced) {
if (announcement.kind === "retracted") continue;
console.log("New stream available:", announcement.path);
console.log("New stream available:", announcement.prefix);

// Subscribe to new streams
const _broadcast = origin.request(announcement.path, { announced: true });
const _broadcast = origin.request(announcement.prefix, { announced: true });

// Do something with the broadcast
}
Expand Down
20 changes: 10 additions & 10 deletions js/net/src/announced.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,20 @@ test("next streams every appended event in order", async () => {
const consumer = producer.consume();

const route = Route.default;
producer.append({ path: p("a"), captures: undefined, kind: "announced", route });
producer.append({ path: p("a"), captures: undefined, kind: "retracted", route });
producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route });
producer.append({ prefix: p("a"), captures: undefined, kind: "retracted", route });

expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "announced", route });
expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "retracted", route });
expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "announced", route });
expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "retracted", route });
});

test("the consumer is an async iterable of the same events", async () => {
const producer = new Announce.Producer();
const consumer = producer.consume();

const route = Route.default;
producer.append({ path: p("a"), captures: undefined, kind: "announced", route });
producer.append({ path: p("a"), captures: undefined, kind: "retracted", route });
producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route });
producer.append({ prefix: p("a"), captures: undefined, kind: "retracted", route });

const events = consumer[Symbol.asyncIterator]();
expect((await events.next()).value?.kind).toBe("announced");
Expand All @@ -50,11 +50,11 @@ test("a same-name re-announce is a distinct update", async () => {
// than collapsing it. Deciding what a repeat means belongs to the session layer, which resolves
// a restart into either nothing (a route change) or an end + start (a new publisher).
const route = Route.default;
producer.append({ path: p("a"), captures: undefined, kind: "announced", route });
producer.append({ path: p("a"), captures: undefined, kind: "announced", route });
producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route });
producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route });

expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "announced", route });
expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "announced", route });
expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "announced", route });
expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "announced", route });
});

test("closing resolves next with undefined", async () => {
Expand Down
15 changes: 8 additions & 7 deletions js/net/src/announced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { Route } from "./hop.js";
import type * as Path from "./path.js";

/**
* What an {@link Update} reports about its path.
* What an {@link Update} reports about its prefix.
*
* @public
*/
Expand All @@ -17,21 +17,22 @@ export type Kind = "announced" | "updated" | "retracted";
/**
* A route announcement, update, or retraction.
*
* A route claims that {@link path} and every path beneath it can be served; it
* carries no broadcast. By convention a publisher announces each broadcast's exact
* path, so enumerating routes enumerates broadcasts; resolve one with the origin's
* `request(path)`. Narrow with a {@link Path.Pattern} locally to follow a subset.
* An announcement is always a prefix, never a broadcast: a route claims that
* {@link prefix} and every path beneath it can be served. By convention a publisher
* announces each broadcast's exact path, so enumerating routes enumerates broadcasts;
* resolve one with the origin's `request(path)`. Narrow with a {@link Path.Pattern}
* locally to follow a subset.
*
* @public
*/
export interface Update {
/**
* The prefix the route covers, relative to the origin (for a session, its URL path).
*/
path: Path.Valid;
prefix: Path.Valid;
/** What the filter's wildcards stood for, when this prefix pins all of them. */
captures: Path.Pattern[] | undefined;
/** Whether the path was announced, re-priced, or retracted. */
/** Whether the prefix was announced, re-priced, or retracted. */
kind: Kind;
/** Hops and cost of the route; on a retraction, its last advertised values. */
route: Route;
Expand Down
4 changes: 2 additions & 2 deletions js/net/src/connection/forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ test("a discovery failure under a live session downgrades the origin", async ()
forwardAnnounced(session.session, origin);

// The relay announces a broadcast, which lands in the table.
session.announces.append({ path, captures: undefined, kind: "announced", route: Route.default });
session.announces.append({ prefix: path, captures: undefined, kind: "announced", route: Route.default });
await settle();
expect(origin.discovery.peek()).toBe(true);
expect(wireOf(origin).routes(path)).toBe(true);
Expand Down Expand Up @@ -116,7 +116,7 @@ test("a request outlives the discovery failure that fed it", async () => {
forwardAnnounced(session.session, origin);

// Announced, so the table routes it and no blind answer is needed.
session.announces.append({ path, captures: undefined, kind: "announced", route: Route.default });
session.announces.append({ prefix: path, captures: undefined, kind: "announced", route: Route.default });
await settle();

const request = origin.request(path);
Expand Down
10 changes: 5 additions & 5 deletions js/net/src/connection/forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,17 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi
if (!event) break;

if (isActive(event.kind)) {
const existing = inserted.get(event.path);
const existing = inserted.get(event.prefix);
if (existing) {
existing.update(event.route);
} else {
const handle = originWire.receive(event.path, event.route);
inserted.set(event.path, handle);
const handle = originWire.receive(event.prefix, event.route);
inserted.set(event.prefix, handle);
void drive(handle, conn);
}
} else {
const handle = inserted.get(event.path);
inserted.delete(event.path);
const handle = inserted.get(event.prefix);
inserted.delete(event.prefix);
handle?.close();
}
}
Expand Down
4 changes: 2 additions & 2 deletions js/net/src/connection/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@ export class Connection {
for (;;) {
const entry = await Promise.race([effect.cancel, upstream.next()]);
if (!entry) break;
if (Announce.isActive(entry.kind)) active.set(entry.path, entry);
else active.delete(entry.path);
if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry);
else active.delete(entry.prefix);
producer.append(entry);
}
} finally {
Expand Down
2 changes: 1 addition & 1 deletion js/net/src/connection/reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ test("closing an announce consumer during upstream teardown does not append retr
const errors = spyOn(console, "error").mockImplementation(() => {});
try {
upstream.append({
path: Path.from("alice/camera.hang"),
prefix: Path.from("alice/camera.hang"),
captures: undefined,
kind: "announced",
route: Route.default,
Expand Down
4 changes: 2 additions & 2 deletions js/net/src/connection/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,8 @@ export class Reload {
for (;;) {
const entry = await Promise.race([effect.cancel, upstream.next()]);
if (!entry) break;
if (Announce.isActive(entry.kind)) active.set(entry.path, entry);
else active.delete(entry.path);
if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry);
else active.delete(entry.prefix);
producer.append(entry);
}
} catch {
Expand Down
Loading
Loading