@xandreed/plugin-render 0.8.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/domain/conformance.entity.d.ts +9 -0
- package/dist/domain/conformance.entity.js +7 -0
- package/dist/domain/feed-frame.entity.d.ts +63 -0
- package/dist/domain/feed-frame.entity.functions.d.ts +20 -0
- package/dist/domain/feed-frame.entity.functions.js +38 -0
- package/dist/domain/feed-frame.entity.js +21 -0
- package/dist/domain/render-output.entity.d.ts +51 -0
- package/dist/domain/render-output.entity.js +33 -0
- package/dist/domain/render-surface.entity.d.ts +164 -0
- package/dist/domain/render-surface.entity.functions.d.ts +22 -0
- package/dist/domain/render-surface.entity.functions.js +36 -0
- package/dist/domain/render-surface.entity.js +63 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/output.d.ts +5 -0
- package/dist/output.js +4 -0
- package/dist/plugin.adapter.d.ts +28 -0
- package/dist/plugin.adapter.js +27 -0
- package/dist/ports/render-output.port.d.ts +40 -0
- package/dist/ports/render-output.port.js +16 -0
- package/dist/ports/render.port.d.ts +86 -0
- package/dist/ports/render.port.js +13 -0
- package/dist/render-feed.adapter.d.ts +14 -0
- package/dist/render-feed.adapter.js +40 -0
- package/dist/render-output.adapter.d.ts +10 -0
- package/dist/render-output.adapter.js +28 -0
- package/dist/render-output.test.d.ts +1 -0
- package/dist/render-output.tools.d.ts +59 -0
- package/dist/render-output.tools.js +10 -0
- package/dist/render-store.memory.adapter.d.ts +69 -0
- package/dist/render-store.memory.adapter.js +47 -0
- package/dist/render-surface.adapter.d.ts +14 -0
- package/dist/render-surface.adapter.js +128 -0
- package/dist/render-surface.conformance.d.ts +18 -0
- package/dist/render-surface.conformance.js +173 -0
- package/dist/render-surface.test.d.ts +1 -0
- package/dist/render-tail.sessions.adapter.d.ts +12 -0
- package/dist/render-tail.sessions.adapter.js +15 -0
- package/dist/render-tail.sessions.test.d.ts +1 -0
- package/dist/render-transport.adapter.d.ts +15 -0
- package/dist/render-transport.adapter.js +27 -0
- package/dist/render-transport.conformance.d.ts +24 -0
- package/dist/render-transport.conformance.js +71 -0
- package/dist/render-transport.test.d.ts +1 -0
- package/package.json +34 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ConformanceFailure } from "./domain/conformance.entity.js";
|
|
3
|
+
import type { RenderError } from "./domain/render-surface.entity.js";
|
|
4
|
+
import type { UiOutputAdmission } from "./ports/render-output.port.js";
|
|
5
|
+
import type { Render, RenderStore } from "./ports/render.port.js";
|
|
6
|
+
export interface ConformanceCheck {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly run: Effect.Effect<void, ConformanceFailure | RenderError>;
|
|
9
|
+
}
|
|
10
|
+
/** A fresh surface implementation and its store for each check, built over the given admission. */
|
|
11
|
+
export interface SurfaceSubject {
|
|
12
|
+
readonly make: (admission: typeof UiOutputAdmission.Service) => Effect.Effect<{
|
|
13
|
+
readonly render: typeof Render.Service;
|
|
14
|
+
readonly store: typeof RenderStore.Service;
|
|
15
|
+
}, RenderError>;
|
|
16
|
+
}
|
|
17
|
+
/** The contract every Render/RenderStore pair must keep, run against any implementation. */
|
|
18
|
+
export declare const renderSurfaceConformance: (subject: SurfaceSubject) => ReadonlyArray<ConformanceCheck>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Effect, Option, Ref, Schema, Stream } from "effect";
|
|
2
|
+
import { canonicalJson } from "@xandreed/core";
|
|
3
|
+
import { ConformanceFailure } from "./domain/conformance.entity.js";
|
|
4
|
+
import { UiOutputError } from "./domain/render-output.entity.js";
|
|
5
|
+
import { SurfaceCommitted, SurfaceCompleted, SurfaceFrozen, SurfacePlanned } from "./domain/render-surface.entity.js";
|
|
6
|
+
const release = (component) => ({
|
|
7
|
+
component,
|
|
8
|
+
version: "1",
|
|
9
|
+
definitionHash: "a".repeat(64),
|
|
10
|
+
rendererRelease: "1",
|
|
11
|
+
tokensHash: "b".repeat(64),
|
|
12
|
+
layoutVersion: "1"
|
|
13
|
+
}), node = (nodeId, component = "card") => ({ nodeId, release: release(component), props: { title: nodeId }, evidence: [`${nodeId}-fact`] }), snapshot = (nodes, phase = "partial", placeholders = []) => ({
|
|
14
|
+
phase,
|
|
15
|
+
spec: { layout: "stack", order: nodes.map((entry) => entry.nodeId) },
|
|
16
|
+
nodes,
|
|
17
|
+
placeholders
|
|
18
|
+
}), scopeOf = (messageId = "message-1") => ({
|
|
19
|
+
threadId: "thread",
|
|
20
|
+
runId: `run-${messageId}`,
|
|
21
|
+
messageId,
|
|
22
|
+
principalId: "guest",
|
|
23
|
+
fence: 1,
|
|
24
|
+
surfaceId: "page",
|
|
25
|
+
baseVersion: Option.none()
|
|
26
|
+
}), detached = { fork: (_tag, work) => Effect.forkDetach(work).pipe(Effect.asVoid) }, allow = { validate: () => Effect.void }, holds = (check) => (condition, message) => condition ? Effect.void : Effect.fail(new ConformanceFailure({ check, message })), failureOf = (check) => (effect) => Effect.matchEffect(effect, {
|
|
27
|
+
onFailure: (error) => Effect.succeed(error),
|
|
28
|
+
onSuccess: () => Effect.fail(new ConformanceFailure({ check, message: "expected a failure" }))
|
|
29
|
+
}), isPlanned = Schema.is(SurfacePlanned), isCommitted = Schema.is(SurfaceCommitted), isCompleted = Schema.is(SurfaceCompleted), planned = (records) => records.filter(isPlanned);
|
|
30
|
+
export const renderSurfaceConformance = (subject) => [
|
|
31
|
+
{
|
|
32
|
+
name: "identical content opens no new generation",
|
|
33
|
+
run: Effect.gen(function* () {
|
|
34
|
+
const expect = holds("identical content opens no new generation"), { render } = yield* subject.make(allow), surface = yield* render.open(scopeOf(), detached), first = yield* surface.publish(snapshot([node("a")])), second = yield* surface.publish(snapshot([node("a")]));
|
|
35
|
+
yield* expect(first.changed && Option.contains(first.version, "message-1:v1"), "the first publish opens message-1:v1");
|
|
36
|
+
yield* expect(!second.changed && second.generation === 1, "the same content keeps generation 1");
|
|
37
|
+
})
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "changed content opens the next generation with its own operation ids",
|
|
41
|
+
run: Effect.gen(function* () {
|
|
42
|
+
const expect = holds("changed content opens the next generation with its own operation ids"), operations = yield* Ref.make([]), { render } = yield* subject.make({ validate: (_scope, proposal) => Ref.update(operations, (all) => [...all, proposal.operationId]) }), surface = yield* render.open(scopeOf(), detached);
|
|
43
|
+
yield* surface.publish(snapshot([node("a")]));
|
|
44
|
+
const second = yield* surface.publish(snapshot([node("a"), node("b")]));
|
|
45
|
+
yield* expect(Option.contains(second.version, "message-1:v2") && second.generation === 2, "the changed content opens message-1:v2");
|
|
46
|
+
const ids = yield* Ref.get(operations);
|
|
47
|
+
yield* expect(canonicalJson(ids) === canonicalJson(["run-message-1:render:1:a", "run-message-1:render:2:a", "run-message-1:render:2:b"]), `operation ids are per generation and node, got ${canonicalJson(ids)}`);
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "completion is recorded once per version",
|
|
52
|
+
run: Effect.gen(function* () {
|
|
53
|
+
const expect = holds("completion is recorded once per version"), { render, store } = yield* subject.make(allow), scope = scopeOf(), surface = yield* render.open(scope, detached);
|
|
54
|
+
yield* surface.publish(snapshot([node("a")], "partial"));
|
|
55
|
+
const done = yield* surface.publish(snapshot([node("a")], "complete"));
|
|
56
|
+
yield* surface.publish(snapshot([node("a")], "complete"));
|
|
57
|
+
const records = yield* store.hydrate(scope);
|
|
58
|
+
yield* expect(!done.changed, "completing the same content opens no generation");
|
|
59
|
+
yield* expect(records.filter((record) => isCompleted(record) && !record.frozen).length === 1, "one completion for the version");
|
|
60
|
+
yield* expect((yield* surface.state).completed, "the state reports completion");
|
|
61
|
+
})
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "a surface frozen elsewhere records a frozen partial completion instead of failing",
|
|
65
|
+
run: Effect.gen(function* () {
|
|
66
|
+
const expect = holds("a surface frozen elsewhere records a frozen partial completion instead of failing"), { render, store } = yield* subject.make(allow), scope = scopeOf(), surface = yield* render.open(scope, detached);
|
|
67
|
+
yield* surface.publish(snapshot([node("a")]));
|
|
68
|
+
yield* store.freeze(scope, SurfaceFrozen.make({ surfaceId: "page", messageId: "message-1", versionId: "message-1:v1", reason: "interaction" }));
|
|
69
|
+
const late = yield* surface.publish(snapshot([node("b")]));
|
|
70
|
+
yield* surface.publish(snapshot([node("c")]));
|
|
71
|
+
const records = yield* store.hydrate(scope);
|
|
72
|
+
yield* expect(late.frozen && !late.changed && Option.contains(late.version, "message-1:v1"), "the publish reports the frozen version");
|
|
73
|
+
yield* expect(planned(records).length === 1, "no generation is planned after the freeze");
|
|
74
|
+
yield* expect(records.filter((record) => isCompleted(record) && record.frozen && record.phase === "partial").length === 1, "one frozen, partial completion");
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "fill commits into a declared placeholder after freezing",
|
|
79
|
+
run: Effect.gen(function* () {
|
|
80
|
+
const expect = holds("fill commits into a declared placeholder after freezing"), { render } = yield* subject.make(allow), surface = yield* render.open(scopeOf(), detached);
|
|
81
|
+
yield* surface.publish(snapshot([node("a")], "complete", ["media"]));
|
|
82
|
+
yield* surface.freeze("completion");
|
|
83
|
+
const receipt = yield* surface.fill("media", node("media", "image")), unknown = yield* failureOf("fill")(surface.fill("other", node("other", "image")));
|
|
84
|
+
yield* expect(receipt.nodeId === "media", "the fill commits the placeholder node");
|
|
85
|
+
yield* expect(unknown.code === "invalid", "an undeclared placeholder is rejected");
|
|
86
|
+
yield* expect((yield* surface.state).components.includes("image"), "the filled component is part of the state");
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "preparations run one at a time and settled waits for them",
|
|
91
|
+
run: Effect.gen(function* () {
|
|
92
|
+
const expect = holds("preparations run one at a time and settled waits for them"), { render, store } = yield* subject.make(allow), scope = scopeOf(), surface = yield* render.open(scope, detached), slowly = (snapshots) => Stream.fromIterable(snapshots).pipe(Stream.tap(() => Effect.sleep("5 millis")));
|
|
93
|
+
yield* surface.prepare(slowly([snapshot([node("first")]), snapshot([node("first"), node("second")])]), () => Effect.void);
|
|
94
|
+
yield* surface.prepare(slowly([snapshot([node("third")], "complete")]), () => Effect.void);
|
|
95
|
+
const during = yield* surface.state;
|
|
96
|
+
yield* surface.settled;
|
|
97
|
+
const after = yield* surface.state, order = planned(yield* store.hydrate(scope)).map((record) => record.nodes.join("+"));
|
|
98
|
+
yield* expect(during.pending && !after.pending, "pending while preparing, not after settled");
|
|
99
|
+
yield* expect(canonicalJson(order) === canonicalJson(["first", "first+second", "third"]), `preparations did not interleave, got ${canonicalJson(order)}`);
|
|
100
|
+
yield* expect(after.completed && after.generation === 3, "the last preparation completed generation 3");
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "a failed preparation is recovered and settles",
|
|
105
|
+
run: Effect.gen(function* () {
|
|
106
|
+
const expect = holds("a failed preparation is recovered and settles"), { render } = yield* subject.make(allow), surface = yield* render.open(scopeOf(), detached), recovered = yield* Ref.make(!1);
|
|
107
|
+
yield* surface.prepare(Stream.fail("composer unavailable"), () => Ref.set(recovered, !0));
|
|
108
|
+
yield* surface.settled;
|
|
109
|
+
yield* expect(yield* Ref.get(recovered), "recover runs with the failure");
|
|
110
|
+
yield* expect(!(yield* surface.state).pending, "nothing is pending afterwards");
|
|
111
|
+
})
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "reopening resumes the generation and deduplication",
|
|
115
|
+
run: Effect.gen(function* () {
|
|
116
|
+
const expect = holds("reopening resumes the generation and deduplication"), { render } = yield* subject.make(allow);
|
|
117
|
+
yield* (yield* render.open(scopeOf(), detached)).publish(snapshot([node("a")]));
|
|
118
|
+
const again = yield* render.open(scopeOf(), detached), state = yield* again.state, repeat = yield* again.publish(snapshot([node("a")]));
|
|
119
|
+
yield* expect(state.generation === 1 && Option.contains(state.version, "message-1:v1"), "the reopened surface is at message-1:v1");
|
|
120
|
+
yield* expect(!repeat.changed, "republishing the same content is deduplicated after reopening");
|
|
121
|
+
yield* expect(again.records.length > 0, "the hydrated records are exposed");
|
|
122
|
+
})
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "a rejected component is never committed",
|
|
126
|
+
run: Effect.gen(function* () {
|
|
127
|
+
const expect = holds("a rejected component is never committed"), { render, store } = yield* subject.make({ validate: () => Effect.fail(new UiOutputError({ code: "forbidden", message: "Release revoked" })) }), scope = scopeOf(), surface = yield* render.open(scope, detached), failure = yield* failureOf("rejected")(surface.publish(snapshot([node("a")])));
|
|
128
|
+
yield* expect(failure.code === "forbidden", "admission failures keep their code");
|
|
129
|
+
yield* expect(!(yield* store.hydrate(scope)).some(isCommitted), "nothing was committed");
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "the store commits idempotently and conflicts on different content",
|
|
134
|
+
run: Effect.gen(function* () {
|
|
135
|
+
const expect = holds("the store commits idempotently and conflicts on different content"), { store } = yield* subject.make(allow), scope = scopeOf(), proposal = { operationId: "op-1", nodeId: "a", release: release("card"), props: { title: "a" }, evidence: ["a-fact"] }, first = yield* store.commit(scope, { versionId: "message-1:v1", proposal }), retry = yield* store.commit(scope, { versionId: "message-1:v1", proposal }), conflict = yield* failureOf("conflict")(store.commit(scope, { versionId: "message-1:v1", proposal: { ...proposal, props: { title: "b" } } }));
|
|
136
|
+
yield* expect(canonicalJson(first) === canonicalJson(retry), "a retried commit returns the same receipt");
|
|
137
|
+
yield* expect(conflict.code === "conflict", "a different proposal under the same operation id conflicts");
|
|
138
|
+
})
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "the store refuses to plan a frozen surface",
|
|
142
|
+
run: Effect.gen(function* () {
|
|
143
|
+
const expect = holds("the store refuses to plan a frozen surface"), { store } = yield* subject.make(allow), scope = scopeOf();
|
|
144
|
+
yield* store.freeze(scope, SurfaceFrozen.make({ surfaceId: "page", messageId: "message-1", versionId: "message-1:v1", reason: "interaction" }));
|
|
145
|
+
const failure = yield* failureOf("frozen plan")(store.plan(scope, SurfacePlanned.make({
|
|
146
|
+
surfaceId: "page",
|
|
147
|
+
messageId: "message-1",
|
|
148
|
+
versionId: "message-1:v2",
|
|
149
|
+
generation: 2,
|
|
150
|
+
baseVersionId: Option.none(),
|
|
151
|
+
phase: "partial",
|
|
152
|
+
spec: {},
|
|
153
|
+
nodes: [],
|
|
154
|
+
placeholders: [],
|
|
155
|
+
signature: "x"
|
|
156
|
+
})));
|
|
157
|
+
yield* expect(failure.code === "frozen", "planning a frozen surface fails with frozen");
|
|
158
|
+
const other = yield* store.plan(scopeOf("message-2"), SurfacePlanned.make({
|
|
159
|
+
surfaceId: "page",
|
|
160
|
+
messageId: "message-2",
|
|
161
|
+
versionId: "message-2:v1",
|
|
162
|
+
generation: 1,
|
|
163
|
+
baseVersionId: Option.none(),
|
|
164
|
+
phase: "partial",
|
|
165
|
+
spec: {},
|
|
166
|
+
nodes: [],
|
|
167
|
+
placeholders: [],
|
|
168
|
+
signature: "y"
|
|
169
|
+
})).pipe(Effect.result);
|
|
170
|
+
yield* expect(other._tag === "Success", "a freeze only binds its own message");
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { Sessions } from "@xandreed/core";
|
|
3
|
+
import { JournalTail } from "./ports/render.port.js";
|
|
4
|
+
/**
|
|
5
|
+
* The feed's journal is a session: `threadId` names it and `principalId` is
|
|
6
|
+
* its owner (another owner reads nothing); each record carries its turn. Reads never write, so following
|
|
7
|
+
* a session runs nothing. `changes` wakes the feed after each commit this
|
|
8
|
+
* instance makes; polling covers the others.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SessionsJournalTailLive: (options?: {
|
|
11
|
+
readonly page: number;
|
|
12
|
+
}) => Layer.Layer<JournalTail, never, Sessions>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Effect, Layer, Option, Schema, Stream } from "effect";
|
|
2
|
+
import { ConversationId, Sessions } from "@xandreed/core";
|
|
3
|
+
import { RenderError } from "./domain/render-surface.entity.js";
|
|
4
|
+
import { JournalTail } from "./ports/render.port.js";
|
|
5
|
+
const decodeId = Schema.decodeUnknownEffect(ConversationId);
|
|
6
|
+
export const SessionsJournalTailLive = (options = { page: 500 }) => Layer.effect(JournalTail, Effect.gen(function* () {
|
|
7
|
+
const sessions = yield* Sessions, address = (feed) => decodeId(feed.threadId).pipe(Effect.map((id) => ({ id, owner: feed.principalId })), Effect.mapError(() => new RenderError({ code: "invalid", message: `not a session id: ${feed.threadId}` })));
|
|
8
|
+
return JournalTail.of({
|
|
9
|
+
read: (feed, after) => address(feed).pipe(Effect.flatMap((session) => sessions.read(session, { after, limit: options.page }).pipe(Effect.mapError((error) => error._tag === "SessionMissing" ? new RenderError({ code: "forbidden", message: "no such session" }) : new RenderError({ code: "storage", message: error.message })))), Effect.map((events) => events.map((event) => ({ sequence: event.seq, kind: event.kind, data: event.data, turn: Option.getOrNull(event.turn) })))),
|
|
10
|
+
changes: (feed) => Stream.unwrap(Effect.match(address(feed), {
|
|
11
|
+
onFailure: () => Stream.empty,
|
|
12
|
+
onSuccess: (session) => sessions.changes(session)
|
|
13
|
+
}))
|
|
14
|
+
});
|
|
15
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { ServerResponse } from "node:http";
|
|
3
|
+
import type { RenderTransport, SocketPeer, SsePeer } from "./ports/render.port.js";
|
|
4
|
+
export declare const sseHeaders: {
|
|
5
|
+
readonly "content-type": "text/event-stream; charset=utf-8";
|
|
6
|
+
readonly "cache-control": "no-store";
|
|
7
|
+
readonly connection: "keep-alive";
|
|
8
|
+
readonly "x-accel-buffering": "no";
|
|
9
|
+
};
|
|
10
|
+
/** Server-Sent Events: a retry preamble, then one block per frame until the feed ends or the client leaves. */
|
|
11
|
+
export declare const SseTransport: RenderTransport<SsePeer>;
|
|
12
|
+
/** One JSON text frame per feed frame. Clients resume by sending `{"after": <sequence>}` or the host's equivalent. */
|
|
13
|
+
export declare const WebSocketTransport: RenderTransport<SocketPeer>;
|
|
14
|
+
/** An SSE peer over a Node HTTP response: writes the SSE headers and completes `closed` on disconnect. */
|
|
15
|
+
export declare const sseNodePeer: (response: ServerResponse, headers?: Readonly<Record<string, string>>) => Effect.Effect<SsePeer>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
import { encodeSocketFrame, encodeSse, ssePreamble } from "./domain/feed-frame.entity.functions.js";
|
|
3
|
+
export const sseHeaders = {
|
|
4
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
5
|
+
"cache-control": "no-store",
|
|
6
|
+
connection: "keep-alive",
|
|
7
|
+
"x-accel-buffering": "no"
|
|
8
|
+
}, SseTransport = {
|
|
9
|
+
id: "sse",
|
|
10
|
+
serve: (frames, peer) => peer.write(ssePreamble).pipe(Effect.andThen(Stream.runForEach(frames, (frame) => peer.write(encodeSse(frame)))), Effect.catch(() => Effect.void), Effect.raceFirst(peer.closed))
|
|
11
|
+
}, WebSocketTransport = {
|
|
12
|
+
id: "websocket",
|
|
13
|
+
serve: (frames, peer) => Stream.runForEach(frames, (frame) => peer.send(encodeSocketFrame(frame))).pipe(Effect.catch(() => Effect.void), Effect.raceFirst(peer.closed))
|
|
14
|
+
}, sseNodePeer = (response, headers = {}) => Effect.sync(() => {
|
|
15
|
+
response.writeHead(200, { ...sseHeaders, ...headers });
|
|
16
|
+
return {
|
|
17
|
+
write: (chunk) => Effect.callback((resume) => {
|
|
18
|
+
response.write(chunk, () => resume(Effect.void));
|
|
19
|
+
}),
|
|
20
|
+
closed: Effect.callback((resume) => {
|
|
21
|
+
if (response.closed)
|
|
22
|
+
resume(Effect.void);
|
|
23
|
+
else
|
|
24
|
+
response.once("close", () => resume(Effect.void));
|
|
25
|
+
})
|
|
26
|
+
};
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
import type { FeedOptions, JournalRecord } from "./domain/feed-frame.entity.js";
|
|
3
|
+
import type { RenderError } from "./domain/render-surface.entity.js";
|
|
4
|
+
import type { JournalTail, RenderFeed, RenderTransport, SocketPeer, SsePeer } from "./ports/render.port.js";
|
|
5
|
+
import type { ConformanceCheck } from "./render-surface.conformance.js";
|
|
6
|
+
/** The two shipped wires must carry the same frames. */
|
|
7
|
+
export declare const renderTransportConformance: (transports: {
|
|
8
|
+
readonly sse: RenderTransport<SsePeer>;
|
|
9
|
+
readonly socket: RenderTransport<SocketPeer>;
|
|
10
|
+
}) => ReadonlyArray<ConformanceCheck>;
|
|
11
|
+
/** An appendable journal for feed checks: `tail` polls only, `wakingTail` also signals each append. */
|
|
12
|
+
export declare const makeMemoryJournal: (initial: ReadonlyArray<JournalRecord>) => Effect.Effect<{
|
|
13
|
+
tail: {
|
|
14
|
+
readonly read: (feed: import("./index.js").FeedScope, after: number) => Effect.Effect<ReadonlyArray<JournalRecord>, RenderError>;
|
|
15
|
+
readonly changes?: (feed: import("./index.js").FeedScope) => Stream.Stream<void, RenderError>;
|
|
16
|
+
};
|
|
17
|
+
wakingTail: {
|
|
18
|
+
readonly read: (feed: import("./index.js").FeedScope, after: number) => Effect.Effect<ReadonlyArray<JournalRecord>, RenderError>;
|
|
19
|
+
readonly changes?: (feed: import("./index.js").FeedScope) => Stream.Stream<void, RenderError>;
|
|
20
|
+
};
|
|
21
|
+
append: (record: JournalRecord) => Effect.Effect<void, never, never>;
|
|
22
|
+
}, never, never>;
|
|
23
|
+
/** The contract of a RenderFeed built over a JournalTail with short intervals. */
|
|
24
|
+
export declare const renderFeedConformance: (makeFeed: (tail: typeof JournalTail.Service, options: FeedOptions) => typeof RenderFeed.Service) => ReadonlyArray<ConformanceCheck>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Effect, Fiber, Option, PubSub, Ref, Stream } from "effect";
|
|
2
|
+
import { canonicalJson } from "@xandreed/core";
|
|
3
|
+
import { ConformanceFailure } from "./domain/conformance.entity.js";
|
|
4
|
+
import { FeedHeartbeat, FeedReady, FeedRecord } from "./domain/feed-frame.entity.js";
|
|
5
|
+
import { decodeSocketFrame, decodeSse } from "./domain/feed-frame.entity.functions.js";
|
|
6
|
+
const holds = (check) => (condition, message) => condition ? Effect.void : Effect.fail(new ConformanceFailure({ check, message })), sample = [
|
|
7
|
+
FeedRecord.make({ sequence: 1, event: "surface.planned", data: { versionId: "m:v1", nested: { list: [1, 2], text: `line
|
|
8
|
+
break` } } }),
|
|
9
|
+
FeedReady.make({}),
|
|
10
|
+
FeedHeartbeat.make({}),
|
|
11
|
+
FeedRecord.make({ sequence: 2, event: "message.updated", data: { text: "\xE9 \u2713" } })
|
|
12
|
+
];
|
|
13
|
+
export const renderTransportConformance = (transports) => [{
|
|
14
|
+
name: "SSE and WebSocket carry identical frames",
|
|
15
|
+
run: Effect.gen(function* () {
|
|
16
|
+
const expect = holds("SSE and WebSocket carry identical frames"), written = yield* Ref.make([]), sent = yield* Ref.make([]);
|
|
17
|
+
yield* transports.sse.serve(Stream.fromIterable(sample), { write: (chunk) => Ref.update(written, (all) => [...all, chunk]), closed: Effect.never });
|
|
18
|
+
yield* transports.socket.serve(Stream.fromIterable(sample), { send: (text) => Ref.update(sent, (all) => [...all, text]), closed: Effect.never });
|
|
19
|
+
const viaSse = decodeSse((yield* Ref.get(written)).join("")), viaSocket = (yield* Ref.get(sent)).flatMap((text) => Option.toArray(decodeSocketFrame(text)));
|
|
20
|
+
yield* expect(canonicalJson(viaSse) === canonicalJson(sample), `SSE frames differ: ${canonicalJson(viaSse)}`);
|
|
21
|
+
yield* expect(canonicalJson(viaSocket) === canonicalJson(sample), `WebSocket frames differ: ${canonicalJson(viaSocket)}`);
|
|
22
|
+
})
|
|
23
|
+
}], makeMemoryJournal = (initial) => Effect.gen(function* () {
|
|
24
|
+
const records = yield* Ref.make(initial), appended = yield* PubSub.unbounded(), tail = {
|
|
25
|
+
read: (_feed, after) => Ref.get(records).pipe(Effect.map((all) => all.filter((record) => record.sequence > after)))
|
|
26
|
+
}, wakingTail = { ...tail, changes: () => Stream.fromPubSub(appended) };
|
|
27
|
+
return {
|
|
28
|
+
tail,
|
|
29
|
+
wakingTail,
|
|
30
|
+
append: (record) => Ref.update(records, (all) => [...all, record]).pipe(Effect.andThen(PubSub.publish(appended, void 0)), Effect.asVoid)
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
const project = (record) => Effect.succeed(record.kind === "hidden" ? Option.none() : Option.some({ event: record.kind, data: record.data })), feedScope = { threadId: "thread", principalId: "guest" }, record = (sequence, kind = "message.updated") => ({ sequence, kind, data: { sequence } }), collect = (stream) => Stream.runCollect(stream).pipe(Effect.map((chunk) => Array.from(chunk))), sequences = (frames) => frames.flatMap((frame) => frame._tag === "FeedRecord" ? [frame.sequence] : []);
|
|
34
|
+
export const renderFeedConformance = (makeFeed) => {
|
|
35
|
+
const options = { pollMs: 5, maxPollMs: 10, heartbeatMs: 30, maxDurationMs: 150 };
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
name: "the feed resumes after the cursor and sends one ready frame",
|
|
39
|
+
run: Effect.gen(function* () {
|
|
40
|
+
const expect = holds("the feed resumes after the cursor and sends one ready frame"), journal = yield* makeMemoryJournal([record(1), record(2), record(3)]), frames = yield* collect(makeFeed(journal.tail, options).frames(feedScope, 1, project));
|
|
41
|
+
yield* expect(canonicalJson(sequences(frames)) === canonicalJson([2, 3]), `records after cursor 1, got ${canonicalJson(sequences(frames))}`);
|
|
42
|
+
yield* expect(frames.filter((frame) => frame._tag === "FeedReady").length === 1, "exactly one ready frame");
|
|
43
|
+
yield* expect(frames.findIndex((frame) => frame._tag === "FeedReady") > frames.findIndex((frame) => frame._tag === "FeedRecord"), "ready follows the backlog");
|
|
44
|
+
})
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "an idle feed sends heartbeats and later records in order",
|
|
48
|
+
run: Effect.gen(function* () {
|
|
49
|
+
const expect = holds("an idle feed sends heartbeats and later records in order"), journal = yield* makeMemoryJournal([record(1)]), running = yield* Effect.forkChild(collect(makeFeed(journal.tail, options).frames(feedScope, 0, project)));
|
|
50
|
+
yield* Effect.sleep("60 millis");
|
|
51
|
+
yield* journal.append(record(2));
|
|
52
|
+
yield* journal.append(record(3, "hidden"));
|
|
53
|
+
yield* journal.append(record(4));
|
|
54
|
+
const frames = yield* Fiber.join(running);
|
|
55
|
+
yield* expect(canonicalJson(sequences(frames)) === canonicalJson([1, 2, 4]), `in order, hidden records skipped, got ${canonicalJson(sequences(frames))}`);
|
|
56
|
+
yield* expect(frames.some((frame) => frame._tag === "FeedHeartbeat"), "a heartbeat while idle");
|
|
57
|
+
})
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "a change signal polls at once instead of waiting out the interval",
|
|
61
|
+
run: Effect.gen(function* () {
|
|
62
|
+
const expect = holds("a change signal polls at once instead of waiting out the interval"), slow = { pollMs: 5000, maxPollMs: 5000, heartbeatMs: 1e4, maxDurationMs: 1e4 }, journal = yield* makeMemoryJournal([record(1)]), running = yield* Effect.forkChild(collect(makeFeed(journal.wakingTail, slow).frames(feedScope, 0, project).pipe(Stream.takeUntil((frame) => frame._tag === "FeedRecord" && frame.sequence === 2))));
|
|
63
|
+
yield* Effect.sleep("50 millis");
|
|
64
|
+
yield* journal.append(record(2));
|
|
65
|
+
const frames = yield* Fiber.join(running).pipe(Effect.timeout("1 second"), Effect.option);
|
|
66
|
+
yield* expect(Option.isSome(frames), "the appended record arrived long before the next poll");
|
|
67
|
+
yield* expect(canonicalJson(sequences(Option.getOrElse(frames, () => []))) === canonicalJson([1, 2]), `records in order, got ${canonicalJson(sequences(Option.getOrElse(frames, () => [])))}`);
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
];
|
|
71
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xandreed/plugin-render",
|
|
3
|
+
"version": "0.8.0-next.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Render surfaces, a journal feed and SSE/WebSocket transports for agent output.",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./output": {
|
|
13
|
+
"types": "./dist/output.d.ts",
|
|
14
|
+
"default": "./dist/output.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@xandreed/core": "0.8.0-next.0",
|
|
19
|
+
"effect": "4.0.0-rc.118"
|
|
20
|
+
},
|
|
21
|
+
"private": false,
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"skills",
|
|
26
|
+
"profiles",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"bun": ">=1.3.0"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|