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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Every meaningful input can leave a trace that explains why the UI changed.

## this is

- A **Bun-native** framework (no webpack, no vite by default — just `bun --hot`)
- A **Bun-hosted runtime with a Vite client pipeline** — Bun owns the program/stream host; Vite owns browser modules, CSS, React Refresh, and React Compiler wiring
- **Effect-native workflow runtime** — typed, composable, testable server logic
- **Resource-driven** — define your data as resources, and the runtime automatically tracks who reads what
- **Program-owned state** — durable workflow truth stays in resources/actions; server-observed view/editing context is modeled as `UIState`
Expand Down Expand Up @@ -119,7 +119,10 @@ const screen = Screen.define("approval.deployments")
.route("/teams/:teamId/deployments", {
params: Schema.Struct({ teamId: Schema.String }),
})
.patchManifest(approvalProjectionPatchManifest)
.regions({
layout: Region.merge(),
pendingDeployments: Region.replace(),
})
.project((view, ctx) =>
Effect.gen(function* () {
return {
Expand Down Expand Up @@ -246,8 +249,9 @@ That's the bet. It might be wrong. But it's the reason this repo exists.
## built with

- **[Bun](https://bun.sh)** — runtime, bundler, test runner, package manager
- **[Vite](https://vite.dev)** — browser client pipeline for modules, CSS, React Refresh, and production assets
- **[Effect](https://effect.website)** — typed effects for server logic
- **[React 19](https://react.dev)** — UI rendering layer
- **[React 19](https://react.dev)** — UI rendering layer with adapter-owned root, provider, optimistic, and error-boundary conventions

---

Expand Down
188 changes: 188 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/design/developer-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@ const ApprovalScreen = Screen.define("approval.deployments")
.route("/teams/:teamId/deployments", {
params: Schema.Struct({ teamId: Schema.String }),
})
.patchManifest(approvalProjectionPatchManifest)
.regions({
pendingDeployments: Region.replace(),
})
.project((view, context) =>
Effect.gen(function* () {
return {
Expand Down
12 changes: 8 additions & 4 deletions docs/design/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,20 @@ flowchart LR
Views --> Trace
```

### Bun Host
### Bun Host And Client Pipeline

The Bun host is the first runtime target.
The Bun host is the first server runtime target. The browser client pipeline can be Bun-built for
small fixtures, but the current default demo path uses Vite for browser modules, CSS, React Refresh,
and React Compiler integration.

Responsibilities:

- serve the development shell
- provide request and socket entrypoints
- load the server program
- host the custom stream transport
- integrate with Bun's bundling and dev server capabilities over time
- run Bun-native asset hooks for styles and other development outputs
- integrate with a client asset pipeline such as Vite without moving program runtime ownership into the client dev server
- keep Bun-native asset hooks available for low-level fixtures and custom style build outputs
- provide the first local development story

Bun should be treated as the practical host, not the whole architecture. The model should still be shaped by serverless constraints: processes can die, memory can disappear, and reconnect should be expected.
Expand Down Expand Up @@ -159,6 +161,8 @@ The React adapter renders projections and hosts React components.
Responsibilities:

- mount the app shell
- provide root hydration/render helpers and React 19 root error callbacks
- expose a provider/context hook layer over the lower-level stream client
- connect browser events to framework inputs
- render server projections
- host client islands
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@
"react-dom": "^19.2.6"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/bun": "^1.3.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vite": "^8.0.13"
}
}
2 changes: 2 additions & 0 deletions src/adapters/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "./program-stream";
export * from "./projection-patch";
export * from "./program-provider";
export * from "./react-adapter";
export * from "./root";
99 changes: 99 additions & 0 deletions src/adapters/react/program-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
import {
useProgramStream,
type ProgramStreamReactOptions,
type ProgramStreamReactState,
} from "./react-adapter";

type ProgramStreamContextValue = {
stream: unknown;
projectionReady: Promise<void>;
};

const ProgramStreamContext = createContext<ProgramStreamContextValue | null>(null);

export function ProgramStreamProvider<
TInput extends { type: string },
TProjection,
TTrace extends { traceId: string },
>(props: { options: ProgramStreamReactOptions<TProjection, TTrace>; children: ReactNode }) {
const stream = useProgramStream<TInput, TProjection, TTrace>(props.options);
const deferred = useRef(createDeferred());

useEffect(() => {
if (stream.projection.value !== null) {
deferred.current.resolve();
}
}, [stream.projection.value]);

const value = useMemo<ProgramStreamContextValue>(
() => ({
stream,
projectionReady: deferred.current.promise,
}),
[stream],
);

return <ProgramStreamContext value={value}>{props.children}</ProgramStreamContext>;
}

export function useProgramStreamState<
TInput extends { type: string },
TProjection,
TTrace extends { traceId: string },
>(): ProgramStreamReactState<TInput, TProjection, TTrace> {
const context = useProgramStreamContext();
return context.stream as ProgramStreamReactState<TInput, TProjection, TTrace>;
}

export function useProgramProjection<TProjection>(options?: {
suspense?: boolean;
}): TProjection | null {
const context = useProgramStreamContext();
const stream = context.stream as ProgramStreamReactState<
{ type: string },
TProjection,
{ traceId: string }
>;
const projection = stream.projection.value;

if (options?.suspense && projection === null) {
throw context.projectionReady;
}

return projection;
}

export function useProgramActions<TInput extends { type: string }>() {
return useProgramStreamState<TInput, unknown, { traceId: string }>().actions;
}

export function useProgramNavigation() {
return useProgramStreamState<{ type: string }, unknown, { traceId: string }>().navigate;
}

export function useProgramErrors() {
return useProgramStreamState<{ type: string }, unknown, { traceId: string }>().errors;
}

function useProgramStreamContext(): ProgramStreamContextValue {
const context = useContext(ProgramStreamContext);

if (!context) {
throw new Error("Program stream hooks must be used inside ProgramStreamProvider");
}

return context;
}

function createDeferred(): {
promise: Promise<void>;
resolve: () => void;
} {
let resolve: () => void = () => undefined;
const promise = new Promise<void>((done) => {
resolve = done;
});

return { promise, resolve };
}
66 changes: 63 additions & 3 deletions src/adapters/react/projection-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ export function applyRegionValuePatch<TProjection>(
}

export function createProjectionPatchApplier<TProjection>(
manifest: ProjectionPatchManifest<TProjection>,
manifest?: ProjectionPatchManifest<TProjection>,
): (projection: TProjection, envelope: ProjectionPatchEnvelope) => TProjection {
return (projection, envelope) =>
applyRegionValuePatchWithManifest(projection, envelope, manifest);
manifest
? applyRegionValuePatchWithManifest(projection, envelope, manifest)
: applyRegionValuePatchAutomatically(projection, envelope);
}

export function applyRegionValuePatchWithManifest<TProjection>(
Expand Down Expand Up @@ -77,21 +79,44 @@ export function applyRegionValuePatchWithManifest<TProjection>(
throw new Error(`No projection patch strategy registered for region: ${region.id}`);
}

next = applyStrategy(next, region.value, strategy);
next = applyStrategy(next, region.id, region.value, strategy);
}

return next;
}

export function applyRegionValuePatchAutomatically<TProjection>(
projection: TProjection,
envelope: ProjectionPatchEnvelope,
): TProjection {
if (envelope.patch.kind !== "region-values") {
return projection;
}

return envelope.patch.regions.reduce(
(current, region) => applyAutomaticRegionPatch(current, region.id, region.value),
projection,
);
}

function applyStrategy<TProjection>(
projection: TProjection,
regionId: string,
value: JsonValue,
strategy: RegionValuePatchStrategy<TProjection>,
): TProjection {
if (strategy.kind === "custom") {
return strategy.apply(projection, value);
}

if (strategy.kind === "replace-region") {
return setPath(projection, [regionId], value);
}

if (strategy.kind === "merge-fields") {
return mergeFields(projection, value);
}

if (strategy.kind === "replace-at-path") {
return setPath(projection, strategy.path, value);
}
Expand All @@ -102,6 +127,41 @@ function applyStrategy<TProjection>(
}, projection);
}

function applyAutomaticRegionPatch<TProjection>(
projection: TProjection,
regionId: string,
value: JsonValue,
): TProjection {
if (
projection !== null &&
typeof projection === "object" &&
!Array.isArray(projection) &&
regionId in projection
) {
return setPath(projection, [regionId], value);
}

return mergeFields(projection, value);
}

function mergeFields<TProjection>(projection: TProjection, value: JsonValue): TProjection {
if (
projection !== null &&
typeof projection === "object" &&
!Array.isArray(projection) &&
value !== null &&
typeof value === "object" &&
!Array.isArray(value)
) {
return {
...(projection as Record<string, unknown>),
...(value as Record<string, unknown>),
} as TProjection;
}

return projection;
}

function setPath<TProjection>(
projection: TProjection,
path: ProjectionPath,
Expand Down
25 changes: 13 additions & 12 deletions src/adapters/react/react-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type ConnectionState,
type ProgramStreamClient,
} from "./program-stream";

import { applyRegionValuePatchAutomatically } from "./projection-patch";
export type ProgramStreamReactOptions<TProjection, TTrace> = {
route: string;
params: Record<string, string>;
Expand Down Expand Up @@ -157,18 +157,8 @@ export function useProgramStream<TInput, TProjection, TTrace extends { traceId:
}
},
onPatch(envelope) {
setCursor(envelope.cursor);
setLastPatch(envelope);

if (!patchProjection) {
setLastError({
type: "error",
viewId: envelope.viewId,
message: "No projection patch applier configured",
});
return;
}

const confirmed = confirmedProjectionRef.current;

if (!confirmed) {
Expand All @@ -183,7 +173,17 @@ export function useProgramStream<TInput, TProjection, TTrace extends { traceId:
let next: TProjection;

try {
next = patchProjection(confirmed, envelope);
if (!patchProjection) {
if (envelope.projectionManifestVersion !== undefined) {
throw new Error(
`Projection patch requires manifest version ${envelope.projectionManifestVersion}, but no applyPatch option is configured`,
);
}

next = applyRegionValuePatchAutomatically(confirmed, envelope);
} else {
next = patchProjection(confirmed, envelope);
}
} catch (error) {
setLastError({
type: "error",
Expand All @@ -193,6 +193,7 @@ export function useProgramStream<TInput, TProjection, TTrace extends { traceId:
return;
}

setCursor(envelope.cursor);
dropProjectionSettledOptimism();
publishProjection(next);

Expand Down
Loading