@beignet/core 0.0.53 → 0.0.55
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/CHANGELOG.md +22 -0
- package/README.md +117 -2
- package/dist/broadcasting/client.d.ts +45 -0
- package/dist/broadcasting/client.d.ts.map +1 -0
- package/dist/broadcasting/client.js +599 -0
- package/dist/broadcasting/client.js.map +1 -0
- package/dist/broadcasting/index.d.ts +45 -0
- package/dist/broadcasting/index.d.ts.map +1 -0
- package/dist/broadcasting/index.js +89 -0
- package/dist/broadcasting/index.js.map +1 -0
- package/dist/broadcasting/server.d.ts +83 -0
- package/dist/broadcasting/server.d.ts.map +1 -0
- package/dist/broadcasting/server.js +213 -0
- package/dist/broadcasting/server.js.map +1 -0
- package/dist/notifications/index.d.ts +14 -0
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +17 -0
- package/dist/notifications/index.js.map +1 -1
- package/dist/ports/redaction.d.ts +1 -1
- package/dist/ports/redaction.d.ts.map +1 -1
- package/dist/ports/redaction.js +3 -0
- package/dist/ports/redaction.js.map +1 -1
- package/dist/server/server.d.ts +4 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1 -1
- package/dist/server/server.js.map +1 -1
- package/package.json +13 -1
- package/skills/app-architecture/SKILL.md +46 -0
- package/src/broadcasting/client.ts +856 -0
- package/src/broadcasting/index.ts +175 -0
- package/src/broadcasting/server.ts +352 -0
- package/src/notifications/index.ts +32 -0
- package/src/ports/redaction.ts +3 -0
- package/src/server/server.ts +8 -3
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import {
|
|
3
|
+
eventTransportValuesEqual,
|
|
4
|
+
toEventTransportValue,
|
|
5
|
+
} from "../events/transport.js";
|
|
6
|
+
|
|
7
|
+
/** Browser-visible channel contract. Authorization belongs in a server binding. */
|
|
8
|
+
export interface ChannelDefinition<
|
|
9
|
+
Name extends string = string,
|
|
10
|
+
Params extends StandardSchemaV1<
|
|
11
|
+
unknown,
|
|
12
|
+
Record<string, string>
|
|
13
|
+
> = StandardSchemaV1<unknown, Record<string, string>>,
|
|
14
|
+
Events extends Record<string, StandardSchemaV1> = Record<
|
|
15
|
+
string,
|
|
16
|
+
StandardSchemaV1
|
|
17
|
+
>,
|
|
18
|
+
> {
|
|
19
|
+
readonly kind: "channel";
|
|
20
|
+
readonly name: Name;
|
|
21
|
+
readonly params: Params;
|
|
22
|
+
readonly events: Events;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type InferChannelParams<C extends ChannelDefinition> =
|
|
26
|
+
StandardSchemaV1.InferOutput<C["params"]>;
|
|
27
|
+
export type InferChannelEvent<C extends ChannelDefinition> = {
|
|
28
|
+
[Event in keyof C["events"] & string]: {
|
|
29
|
+
event: Event;
|
|
30
|
+
data: StandardSchemaV1.InferOutput<C["events"][Event]>;
|
|
31
|
+
};
|
|
32
|
+
}[keyof C["events"] & string];
|
|
33
|
+
|
|
34
|
+
/** Protocol defaults and safety limits shared by clients, endpoints, and providers. */
|
|
35
|
+
export const broadcastLimits = Object.freeze({
|
|
36
|
+
subscriptions: 20,
|
|
37
|
+
requestBytes: 8_192,
|
|
38
|
+
payloadBytes: 65_536,
|
|
39
|
+
bufferedBytes: 1_048_576,
|
|
40
|
+
defaultLifetimeMs: 60_000,
|
|
41
|
+
maxLifetimeMs: 3_600_000,
|
|
42
|
+
lifetimeGraceMs: 5_000,
|
|
43
|
+
heartbeatMs: 25_000,
|
|
44
|
+
readyTimeoutMs: 10_000,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Invalid channel configuration or untrusted protocol input. */
|
|
48
|
+
export class BroadcastValidationError extends Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "BroadcastValidationError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const namePattern = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
|
|
56
|
+
|
|
57
|
+
export function defineChannel<
|
|
58
|
+
const Name extends string,
|
|
59
|
+
Params extends StandardSchemaV1<unknown, Record<string, string>>,
|
|
60
|
+
const Events extends Record<string, StandardSchemaV1>,
|
|
61
|
+
>(
|
|
62
|
+
name: Name,
|
|
63
|
+
options: { params: Params; events: Events },
|
|
64
|
+
): ChannelDefinition<Name, Params, Events> {
|
|
65
|
+
if (!namePattern.test(name))
|
|
66
|
+
throw new BroadcastValidationError("Invalid broadcast channel name");
|
|
67
|
+
const events = Object.entries(options.events);
|
|
68
|
+
if (!events.length || events.some(([event]) => !namePattern.test(event))) {
|
|
69
|
+
throw new BroadcastValidationError(
|
|
70
|
+
`Channel ${name} needs valid event names`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
kind: "channel",
|
|
75
|
+
name,
|
|
76
|
+
params: options.params,
|
|
77
|
+
events: Object.freeze({ ...options.events }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Validate both sides of a transport boundary; schema transforms must be stable. */
|
|
82
|
+
async function parseStable(
|
|
83
|
+
schema: StandardSchemaV1,
|
|
84
|
+
input: unknown,
|
|
85
|
+
label: string,
|
|
86
|
+
): Promise<unknown> {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = await schema["~standard"].validate(input);
|
|
89
|
+
if (parsed.issues) throw new BroadcastValidationError(`Invalid ${label}`);
|
|
90
|
+
const canonical = toEventTransportValue(label, parsed.value);
|
|
91
|
+
const second = await schema["~standard"].validate(canonical);
|
|
92
|
+
if (
|
|
93
|
+
second.issues ||
|
|
94
|
+
!eventTransportValuesEqual(
|
|
95
|
+
canonical,
|
|
96
|
+
toEventTransportValue(label, second.value),
|
|
97
|
+
)
|
|
98
|
+
) {
|
|
99
|
+
throw new BroadcastValidationError(
|
|
100
|
+
`Unstable transport schema for ${label}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return canonical;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof BroadcastValidationError) throw error;
|
|
106
|
+
throw new BroadcastValidationError(`Invalid ${label}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Provider and transport boundary validation. Uses parsed, canonical parameters. */
|
|
111
|
+
export async function parseChannelParams<C extends ChannelDefinition>(
|
|
112
|
+
channel: C,
|
|
113
|
+
input: unknown,
|
|
114
|
+
): Promise<InferChannelParams<C>> {
|
|
115
|
+
const parsed = await parseStable(
|
|
116
|
+
channel.params,
|
|
117
|
+
input,
|
|
118
|
+
`${channel.name} parameters`,
|
|
119
|
+
);
|
|
120
|
+
if (
|
|
121
|
+
!parsed ||
|
|
122
|
+
typeof parsed !== "object" ||
|
|
123
|
+
Array.isArray(parsed) ||
|
|
124
|
+
Object.values(parsed).some((value) => typeof value !== "string")
|
|
125
|
+
) {
|
|
126
|
+
throw new BroadcastValidationError(
|
|
127
|
+
`Channel ${channel.name} parameters must be a flat string record`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
new TextEncoder().encode(JSON.stringify(parsed)).byteLength >
|
|
132
|
+
broadcastLimits.requestBytes
|
|
133
|
+
) {
|
|
134
|
+
throw new BroadcastValidationError(
|
|
135
|
+
`Channel ${channel.name} parameters are too large`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return parsed as InferChannelParams<C>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Provider and browser boundary validation. Event names select their schema. */
|
|
142
|
+
export async function parseChannelEvent<C extends ChannelDefinition>(
|
|
143
|
+
channel: C,
|
|
144
|
+
input: { event: string; data: unknown },
|
|
145
|
+
): Promise<InferChannelEvent<C>> {
|
|
146
|
+
if (!Object.hasOwn(channel.events, input.event))
|
|
147
|
+
throw new BroadcastValidationError(
|
|
148
|
+
`Unknown event on channel ${channel.name}`,
|
|
149
|
+
);
|
|
150
|
+
const data = await parseStable(
|
|
151
|
+
channel.events[input.event],
|
|
152
|
+
input.data,
|
|
153
|
+
`${channel.name}.${input.event}`,
|
|
154
|
+
);
|
|
155
|
+
if (
|
|
156
|
+
new TextEncoder().encode(JSON.stringify(data)).byteLength >
|
|
157
|
+
broadcastLimits.payloadBytes
|
|
158
|
+
) {
|
|
159
|
+
throw new BroadcastValidationError(
|
|
160
|
+
`Channel ${channel.name} payload is too large`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return { event: input.event, data } as InferChannelEvent<C>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Collision-free identity independent of parameter property order. Parse parameters first. */
|
|
167
|
+
export function channelKey(
|
|
168
|
+
channel: string,
|
|
169
|
+
params: Readonly<Record<string, string>>,
|
|
170
|
+
): string {
|
|
171
|
+
return JSON.stringify([
|
|
172
|
+
channel,
|
|
173
|
+
Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import "../server-only.js";
|
|
2
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
import {
|
|
4
|
+
BroadcastValidationError,
|
|
5
|
+
broadcastLimits,
|
|
6
|
+
type ChannelDefinition,
|
|
7
|
+
channelKey,
|
|
8
|
+
type InferChannelEvent,
|
|
9
|
+
type InferChannelParams,
|
|
10
|
+
parseChannelEvent,
|
|
11
|
+
parseChannelParams,
|
|
12
|
+
} from "./index.js";
|
|
13
|
+
|
|
14
|
+
/** Server-authenticated scope captured at the originating HTTP boundary. JSON-safe for jobs. */
|
|
15
|
+
export interface BroadcastOrigin {
|
|
16
|
+
readonly version: 1;
|
|
17
|
+
readonly clientId: string;
|
|
18
|
+
readonly scope: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const clientIdPattern = /^[a-zA-Z0-9_-]{22,128}$/;
|
|
22
|
+
|
|
23
|
+
function parseOrigin(value: unknown): BroadcastOrigin | undefined {
|
|
24
|
+
if (!value || typeof value !== "object") return undefined;
|
|
25
|
+
const candidate = value as Record<string, unknown>;
|
|
26
|
+
if (
|
|
27
|
+
candidate.version !== 1 ||
|
|
28
|
+
typeof candidate.clientId !== "string" ||
|
|
29
|
+
!clientIdPattern.test(candidate.clientId) ||
|
|
30
|
+
typeof candidate.scope !== "string" ||
|
|
31
|
+
candidate.scope.length === 0 ||
|
|
32
|
+
candidate.scope.length > 2_048
|
|
33
|
+
)
|
|
34
|
+
return undefined;
|
|
35
|
+
return { version: 1, clientId: candidate.clientId, scope: candidate.scope };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Validate an origin already captured by a trusted server for a job payload. */
|
|
39
|
+
export const broadcastOriginSchema: StandardSchemaV1<unknown, BroadcastOrigin> =
|
|
40
|
+
{
|
|
41
|
+
"~standard": {
|
|
42
|
+
version: 1,
|
|
43
|
+
vendor: "beignet",
|
|
44
|
+
validate(value) {
|
|
45
|
+
const origin = parseOrigin(value);
|
|
46
|
+
return origin
|
|
47
|
+
? { value: origin }
|
|
48
|
+
: { issues: [{ message: "Invalid broadcast origin" }] };
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Never derive principal, tenant, or namespace from the optional client header. */
|
|
54
|
+
export function resolveBroadcastOrigin(options: {
|
|
55
|
+
headers: Headers;
|
|
56
|
+
principalId?: string | null;
|
|
57
|
+
tenantId?: string | null;
|
|
58
|
+
namespace: string;
|
|
59
|
+
}): BroadcastOrigin | undefined {
|
|
60
|
+
if (!options.principalId || !options.namespace) return undefined;
|
|
61
|
+
return parseOrigin({
|
|
62
|
+
version: 1,
|
|
63
|
+
clientId: options.headers.get("X-Beignet-Broadcast-Client"),
|
|
64
|
+
scope: JSON.stringify([
|
|
65
|
+
options.namespace,
|
|
66
|
+
options.tenantId ?? null,
|
|
67
|
+
options.principalId,
|
|
68
|
+
]),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type BroadcastPublication<C extends ChannelDefinition> =
|
|
73
|
+
InferChannelEvent<C> & {
|
|
74
|
+
params: InferChannelParams<C>;
|
|
75
|
+
excludeOrigin?: BroadcastOrigin;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Readiness proves initial subscription only. Continuity loss requires reconnect and refetch. */
|
|
79
|
+
export interface BroadcastSubscription {
|
|
80
|
+
readonly ready: Promise<void>;
|
|
81
|
+
unsubscribe(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface BroadcastPort {
|
|
85
|
+
/** Resolves when accepted by the provider, including when nobody is subscribed. */
|
|
86
|
+
publish<C extends ChannelDefinition>(
|
|
87
|
+
channel: C,
|
|
88
|
+
publication: BroadcastPublication<C>,
|
|
89
|
+
): Promise<void>;
|
|
90
|
+
subscribe<C extends ChannelDefinition>(
|
|
91
|
+
channel: C,
|
|
92
|
+
options: {
|
|
93
|
+
params: InferChannelParams<C>;
|
|
94
|
+
onEvent: (
|
|
95
|
+
event: InferChannelEvent<C>,
|
|
96
|
+
metadata: { excludeOrigin?: BroadcastOrigin },
|
|
97
|
+
) => void;
|
|
98
|
+
onDisconnect: () => void;
|
|
99
|
+
},
|
|
100
|
+
): BroadcastSubscription;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Internal transport envelope carried by broadcast providers, never a browser payload. */
|
|
104
|
+
export interface BroadcastEnvelope {
|
|
105
|
+
version: 1;
|
|
106
|
+
channel: string;
|
|
107
|
+
params: Record<string, string>;
|
|
108
|
+
event: string;
|
|
109
|
+
data: unknown;
|
|
110
|
+
excludeOrigin?: BroadcastOrigin;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Low-level adapter contract. Subscription callbacks must be isolated from publishers. */
|
|
114
|
+
export interface BroadcastTransport {
|
|
115
|
+
publish(key: string, envelope: BroadcastEnvelope): Promise<void>;
|
|
116
|
+
subscribe(
|
|
117
|
+
key: string,
|
|
118
|
+
options: {
|
|
119
|
+
onMessage: (envelope: unknown) => void;
|
|
120
|
+
onDisconnect: () => void;
|
|
121
|
+
},
|
|
122
|
+
): BroadcastSubscription;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Shared validation and cleanup for provider implementations. */
|
|
126
|
+
export function createBroadcastPort(
|
|
127
|
+
transport: BroadcastTransport,
|
|
128
|
+
): BroadcastPort {
|
|
129
|
+
return {
|
|
130
|
+
async publish(channel, publication) {
|
|
131
|
+
const params = await parseChannelParams(channel, publication.params);
|
|
132
|
+
const event = await parseChannelEvent(channel, publication);
|
|
133
|
+
const excludeOrigin =
|
|
134
|
+
publication.excludeOrigin === undefined
|
|
135
|
+
? undefined
|
|
136
|
+
: parseOrigin(publication.excludeOrigin);
|
|
137
|
+
if (publication.excludeOrigin !== undefined && !excludeOrigin)
|
|
138
|
+
throw new BroadcastValidationError("Invalid broadcast origin");
|
|
139
|
+
await transport.publish(channelKey(channel.name, params), {
|
|
140
|
+
version: 1,
|
|
141
|
+
channel: channel.name,
|
|
142
|
+
params,
|
|
143
|
+
...event,
|
|
144
|
+
...(excludeOrigin ? { excludeOrigin } : {}),
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
subscribe(channel, options) {
|
|
148
|
+
let stopped = false;
|
|
149
|
+
let failed = false;
|
|
150
|
+
let subscription: BroadcastSubscription | undefined;
|
|
151
|
+
let queued = 0;
|
|
152
|
+
let queuedBytes = 0;
|
|
153
|
+
let pending = Promise.resolve();
|
|
154
|
+
let cancel: (() => void) | undefined;
|
|
155
|
+
let cleanup: Promise<void> | undefined;
|
|
156
|
+
const release = () =>
|
|
157
|
+
(cleanup ??= subscription?.unsubscribe() ?? Promise.resolve());
|
|
158
|
+
const disconnect = () => {
|
|
159
|
+
if (stopped || failed) return;
|
|
160
|
+
failed = true;
|
|
161
|
+
try {
|
|
162
|
+
options.onDisconnect();
|
|
163
|
+
} catch {
|
|
164
|
+
/* Consumer callbacks cannot break provider lifecycle. */
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const initialize = (async () => {
|
|
168
|
+
const params = await parseChannelParams(channel, options.params);
|
|
169
|
+
if (stopped) return;
|
|
170
|
+
const key = channelKey(channel.name, params);
|
|
171
|
+
subscription = transport.subscribe(key, {
|
|
172
|
+
onDisconnect: disconnect,
|
|
173
|
+
onMessage(value) {
|
|
174
|
+
if (stopped || failed) return;
|
|
175
|
+
let bytes: number;
|
|
176
|
+
try {
|
|
177
|
+
bytes = new TextEncoder().encode(
|
|
178
|
+
JSON.stringify(value),
|
|
179
|
+
).byteLength;
|
|
180
|
+
} catch {
|
|
181
|
+
disconnect();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (
|
|
185
|
+
bytes >
|
|
186
|
+
broadcastLimits.payloadBytes +
|
|
187
|
+
broadcastLimits.requestBytes +
|
|
188
|
+
16_384 ||
|
|
189
|
+
queuedBytes + bytes > broadcastLimits.bufferedBytes
|
|
190
|
+
) {
|
|
191
|
+
disconnect();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (++queued > 128) {
|
|
195
|
+
disconnect();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
queuedBytes += bytes;
|
|
199
|
+
pending = pending
|
|
200
|
+
.then(async () => {
|
|
201
|
+
if (stopped || failed) return;
|
|
202
|
+
if (!value || typeof value !== "object")
|
|
203
|
+
throw new BroadcastValidationError(
|
|
204
|
+
"Invalid broadcast envelope",
|
|
205
|
+
);
|
|
206
|
+
const envelope = value as BroadcastEnvelope;
|
|
207
|
+
if (envelope.version !== 1 || envelope.channel !== channel.name)
|
|
208
|
+
throw new BroadcastValidationError(
|
|
209
|
+
"Invalid broadcast envelope",
|
|
210
|
+
);
|
|
211
|
+
const receivedParams = await parseChannelParams(
|
|
212
|
+
channel,
|
|
213
|
+
envelope.params,
|
|
214
|
+
);
|
|
215
|
+
if (channelKey(channel.name, receivedParams) !== key)
|
|
216
|
+
throw new BroadcastValidationError(
|
|
217
|
+
"Broadcast channel mismatch",
|
|
218
|
+
);
|
|
219
|
+
const event = await parseChannelEvent(channel, envelope);
|
|
220
|
+
const excludeOrigin =
|
|
221
|
+
envelope.excludeOrigin === undefined
|
|
222
|
+
? undefined
|
|
223
|
+
: parseOrigin(envelope.excludeOrigin);
|
|
224
|
+
if (envelope.excludeOrigin !== undefined && !excludeOrigin)
|
|
225
|
+
throw new BroadcastValidationError(
|
|
226
|
+
"Invalid broadcast origin",
|
|
227
|
+
);
|
|
228
|
+
if (!stopped && !failed)
|
|
229
|
+
await options.onEvent(event, { excludeOrigin });
|
|
230
|
+
})
|
|
231
|
+
.catch(disconnect)
|
|
232
|
+
.finally(() => {
|
|
233
|
+
queued--;
|
|
234
|
+
queuedBytes -= bytes;
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
await subscription.ready;
|
|
239
|
+
if (stopped) await release();
|
|
240
|
+
if (failed)
|
|
241
|
+
throw new Error(
|
|
242
|
+
"Broadcast subscription lost continuity before readiness",
|
|
243
|
+
);
|
|
244
|
+
})();
|
|
245
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
246
|
+
const ready = Promise.race([
|
|
247
|
+
initialize,
|
|
248
|
+
new Promise<never>((_, reject) => {
|
|
249
|
+
cancel = () => reject(new Error("Broadcast subscription cancelled"));
|
|
250
|
+
timeout = setTimeout(
|
|
251
|
+
() =>
|
|
252
|
+
reject(new Error("Broadcast subscription readiness timed out")),
|
|
253
|
+
broadcastLimits.readyTimeoutMs,
|
|
254
|
+
);
|
|
255
|
+
}),
|
|
256
|
+
])
|
|
257
|
+
.catch((error) => {
|
|
258
|
+
stopped = true;
|
|
259
|
+
void release().catch(() => undefined);
|
|
260
|
+
throw error;
|
|
261
|
+
})
|
|
262
|
+
.finally(() => {
|
|
263
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
264
|
+
cancel = undefined;
|
|
265
|
+
});
|
|
266
|
+
// Callers still observe rejection through ready; early cancellation must not leak a rejection.
|
|
267
|
+
void ready.catch(() => undefined);
|
|
268
|
+
return {
|
|
269
|
+
ready,
|
|
270
|
+
async unsubscribe() {
|
|
271
|
+
stopped = true;
|
|
272
|
+
cancel?.();
|
|
273
|
+
await ready.catch(() => undefined);
|
|
274
|
+
await release();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface ChannelBinding<Ctx> {
|
|
282
|
+
readonly kind: "channel-binding";
|
|
283
|
+
readonly channel: ChannelDefinition;
|
|
284
|
+
authorize: (args: {
|
|
285
|
+
ctx: Ctx;
|
|
286
|
+
params: Record<string, string>;
|
|
287
|
+
}) => void | Promise<void>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ChannelRegistry<Ctx> {
|
|
291
|
+
readonly kind: "channel-registry";
|
|
292
|
+
readonly bindings: readonly ChannelBinding<Ctx>[];
|
|
293
|
+
get(name: string): ChannelBinding<Ctx> | undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Bind every channel explicitly, including public channels. */
|
|
297
|
+
export function createBroadcasting<Ctx>() {
|
|
298
|
+
function defineChannelBinding<C extends ChannelDefinition>(
|
|
299
|
+
channel: C,
|
|
300
|
+
options: {
|
|
301
|
+
authorize: (args: {
|
|
302
|
+
ctx: Ctx;
|
|
303
|
+
params: InferChannelParams<C>;
|
|
304
|
+
}) => void | Promise<void>;
|
|
305
|
+
},
|
|
306
|
+
): ChannelBinding<Ctx> {
|
|
307
|
+
return Object.freeze({
|
|
308
|
+
kind: "channel-binding" as const,
|
|
309
|
+
channel,
|
|
310
|
+
authorize: ({
|
|
311
|
+
ctx,
|
|
312
|
+
params,
|
|
313
|
+
}: {
|
|
314
|
+
ctx: Ctx;
|
|
315
|
+
params: Record<string, string>;
|
|
316
|
+
}) => options.authorize({ ctx, params: params as InferChannelParams<C> }),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
function defineChannelRegistry(
|
|
320
|
+
bindings: readonly ChannelBinding<Ctx>[],
|
|
321
|
+
): ChannelRegistry<Ctx> {
|
|
322
|
+
const byName = new Map<string, ChannelBinding<Ctx>>();
|
|
323
|
+
for (const binding of bindings) {
|
|
324
|
+
if (byName.has(binding.channel.name))
|
|
325
|
+
throw new BroadcastValidationError(
|
|
326
|
+
`Duplicate channel ${binding.channel.name}`,
|
|
327
|
+
);
|
|
328
|
+
byName.set(binding.channel.name, binding);
|
|
329
|
+
}
|
|
330
|
+
return Object.freeze({
|
|
331
|
+
kind: "channel-registry",
|
|
332
|
+
bindings: Object.freeze([...bindings]),
|
|
333
|
+
get: (name: string) => byName.get(name),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return { defineChannelBinding, defineChannelRegistry };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Validate a configurable deadline without allowing an unbounded stream. */
|
|
340
|
+
export function broadcastLifetime(
|
|
341
|
+
value: number = broadcastLimits.defaultLifetimeMs,
|
|
342
|
+
): number {
|
|
343
|
+
if (
|
|
344
|
+
!Number.isSafeInteger(value) ||
|
|
345
|
+
value < 1 ||
|
|
346
|
+
value > broadcastLimits.maxLifetimeMs
|
|
347
|
+
)
|
|
348
|
+
throw new BroadcastValidationError(
|
|
349
|
+
`Broadcast maxLifetimeMs must be a safe integer between 1 and ${broadcastLimits.maxLifetimeMs} milliseconds`,
|
|
350
|
+
);
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { ChannelDefinition } from "../broadcasting/index.js";
|
|
3
|
+
import type {
|
|
4
|
+
BroadcastPort,
|
|
5
|
+
BroadcastPublication,
|
|
6
|
+
} from "../broadcasting/server.js";
|
|
2
7
|
import {
|
|
3
8
|
createJobs,
|
|
4
9
|
type JobDef,
|
|
@@ -1345,6 +1350,33 @@ export function defineMailNotificationChannel<
|
|
|
1345
1350
|
};
|
|
1346
1351
|
}
|
|
1347
1352
|
|
|
1353
|
+
/** Deliver a typed browser hint through the existing notification delivery pipeline.
|
|
1354
|
+
* `sent` means provider acceptance, including when the recipient is offline.
|
|
1355
|
+
* Return undefined to skip. Persistent inbox writes and their outbox ordering belong to the app.
|
|
1356
|
+
*/
|
|
1357
|
+
export function defineBroadcastNotificationChannel<
|
|
1358
|
+
C extends ChannelDefinition,
|
|
1359
|
+
Payload extends StandardSchema,
|
|
1360
|
+
Ctx extends { ports: { broadcast: BroadcastPort } },
|
|
1361
|
+
>(options: {
|
|
1362
|
+
channel: C;
|
|
1363
|
+
render: (
|
|
1364
|
+
args: NotificationChannelHandleArgs<Payload, Ctx>,
|
|
1365
|
+
) => MaybePromise<BroadcastPublication<NoInfer<C>> | undefined>;
|
|
1366
|
+
}): NotificationChannelHandler<Payload, Ctx> {
|
|
1367
|
+
return async (args) => {
|
|
1368
|
+
const publication = await options.render(args);
|
|
1369
|
+
if (!publication)
|
|
1370
|
+
return {
|
|
1371
|
+
channel: args.channel,
|
|
1372
|
+
status: "skipped",
|
|
1373
|
+
reason: "No broadcast was returned.",
|
|
1374
|
+
};
|
|
1375
|
+
await args.ctx.ports.broadcast.publish(options.channel, publication);
|
|
1376
|
+
return { channel: args.channel, status: "sent" };
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1348
1380
|
/**
|
|
1349
1381
|
* Create an in-memory notification port for tests and examples.
|
|
1350
1382
|
*
|
package/src/ports/redaction.ts
CHANGED
package/src/server/server.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { createContextFinalizer, resolveServerContext } from "./context.js";
|
|
|
29
29
|
import type { ContractLike, ResolveContract } from "./contract-like.js";
|
|
30
30
|
import { resolveContract } from "./contract-like.js";
|
|
31
31
|
import type {
|
|
32
|
+
AddedCtxFromHooks,
|
|
32
33
|
Handler,
|
|
33
34
|
HttpRequestLike,
|
|
34
35
|
HttpResponse,
|
|
@@ -312,7 +313,11 @@ export interface ServerInstance<
|
|
|
312
313
|
* are not added to the route registry; mount the returned handler at the
|
|
313
314
|
* route's own path.
|
|
314
315
|
*/
|
|
315
|
-
rawRoute:
|
|
316
|
+
rawRoute: <
|
|
317
|
+
const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
|
|
318
|
+
>(
|
|
319
|
+
init: RawRouteInit & { hooks?: Hooks },
|
|
320
|
+
) => RawRouteBuilder<Ctx & AddedCtxFromHooks<Hooks>>;
|
|
316
321
|
/**
|
|
317
322
|
* Build a fully assembled request context from a framework-neutral request.
|
|
318
323
|
*
|
|
@@ -960,9 +965,9 @@ export async function createServer<
|
|
|
960
965
|
finalPorts,
|
|
961
966
|
contextRuntime,
|
|
962
967
|
rawRouteContract(init),
|
|
963
|
-
fn,
|
|
968
|
+
fn as Handler<Ctx, HttpContractConfig>,
|
|
964
969
|
hooks,
|
|
965
|
-
[],
|
|
970
|
+
(init.hooks ?? []) as readonly RouteHook<unknown, object>[],
|
|
966
971
|
{ rawRoute: true },
|
|
967
972
|
);
|
|
968
973
|
// The adapter owns routing for raw routes — the handler is mounted
|