broapp 0.1.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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `broapp/shared` — everything both sides import.
3
+ *
4
+ * This entry point holds no host code and no browser code, only the contract
5
+ * description and the types derived from it. That is what makes it safe for
6
+ * the browser bundle to follow.
7
+ */
8
+ export { defineContract, splitRoute } from './contract.ts';
9
+ export type {
10
+ AnyContract,
11
+ Contract,
12
+ ContractShape,
13
+ ShapeOf,
14
+ OperationInput,
15
+ OperationName,
16
+ OperationOutput,
17
+ OperationSpec,
18
+ StreamEvent,
19
+ StreamName,
20
+ StreamParams,
21
+ StreamSpec,
22
+ } from './contract.ts';
23
+
24
+ export { s, ValidationError } from './schema.ts';
25
+ export type { Infer, Issue, Result, Schema } from './schema.ts';
26
+
27
+ export {
28
+ BroappError,
29
+ INTERNAL_ERROR_MESSAGE,
30
+ PublicError,
31
+ fromTransportError,
32
+ publicError,
33
+ } from './errors.ts';
34
+ export type { PublicErrorCode } from './errors.ts';
35
+
36
+ export { encodeEvent, MAX_EVENT_BYTES, NdjsonDecoder } from './ndjson.ts';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Newline-delimited JSON over a Brobridge byte stream.
3
+ *
4
+ * A Brobridge stream is an ordered byte channel, not a message channel: a
5
+ * chunk delivered to a consumer may hold two events, half an event, or the
6
+ * tail of one and the head of the next. Treating a chunk as an event works
7
+ * until a payload crosses a frame boundary and then fails in production, so
8
+ * Broapp frames explicitly.
9
+ *
10
+ * NDJSON is used rather than a length prefix because it stays readable in a
11
+ * packet dump and costs one byte per event.
12
+ */
13
+
14
+ const encoder = new TextEncoder();
15
+
16
+ /** Encode one event as a single NDJSON line. */
17
+ export function encodeEvent(event: unknown): Uint8Array {
18
+ const line = JSON.stringify(event);
19
+ if (line === undefined) throw new TypeError('stream events must be JSON-encodable');
20
+ if (line.includes('\n')) throw new TypeError('encoded event contains a newline');
21
+ return encoder.encode(`${line}\n`);
22
+ }
23
+
24
+ /** Largest single event Broapp will reassemble, in bytes of UTF-8. */
25
+ export const MAX_EVENT_BYTES = 4 * 1024 * 1024;
26
+
27
+ /**
28
+ * Reassemble NDJSON events from arbitrary byte chunks.
29
+ *
30
+ * The buffer is bounded: a peer that sends four megabytes without a newline
31
+ * is a fault, not a slow event, and the decoder throws rather than growing.
32
+ */
33
+ export class NdjsonDecoder {
34
+ readonly #decoder = new TextDecoder('utf-8');
35
+ readonly #limit: number;
36
+ #buffer = '';
37
+
38
+ constructor(limit: number = MAX_EVENT_BYTES) {
39
+ this.#limit = limit;
40
+ }
41
+
42
+ /** Feed one chunk; returns every event that completed within it. */
43
+ push(chunk: Uint8Array): unknown[] {
44
+ this.#buffer += this.#decoder.decode(chunk, { stream: true });
45
+ const events: unknown[] = [];
46
+ for (;;) {
47
+ const cut = this.#buffer.indexOf('\n');
48
+ if (cut < 0) break;
49
+ const line = this.#buffer.slice(0, cut);
50
+ this.#buffer = this.#buffer.slice(cut + 1);
51
+ if (line.trim() !== '') events.push(JSON.parse(line));
52
+ }
53
+ if (this.#buffer.length > this.#limit) {
54
+ throw new Error('stream event exceeded the maximum size');
55
+ }
56
+ return events;
57
+ }
58
+
59
+ /**
60
+ * Finish. Returns a trailing event that had no newline, if any.
61
+ *
62
+ * A well-behaved producer always terminates its last line, so this normally
63
+ * returns an empty array; it exists so a stream ended by `end()` mid-line
64
+ * does not silently drop its final event.
65
+ */
66
+ flush(): unknown[] {
67
+ this.#buffer += this.#decoder.decode();
68
+ const rest = this.#buffer.trim();
69
+ this.#buffer = '';
70
+ return rest === '' ? [] : [JSON.parse(rest)];
71
+ }
72
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A very small runtime validator.
3
+ *
4
+ * Broapp needs three things from a validation library: it must describe JSON
5
+ * shapes, it must infer a TypeScript type from a schema value, and it must
6
+ * refuse untrusted input on the host. It does not need transforms, unions of
7
+ * objects, effects, or async refinement. Everything here is roughly 250 lines
8
+ * and has no dependencies, which keeps a generated application installable
9
+ * and runnable without a network and keeps the browser bundle small.
10
+ *
11
+ * If an application outgrows it, nothing in Broapp requires these schemas —
12
+ * `defineContract` accepts any object with a `parse` method, so `zod`,
13
+ * `valibot` or `arktype` drop in unchanged.
14
+ */
15
+
16
+ /** Where a validation failure happened, as a dotted path from the root value. */
17
+ export type IssuePath = readonly (string | number)[];
18
+
19
+ /** One validation failure. */
20
+ export interface Issue {
21
+ readonly path: IssuePath;
22
+ readonly message: string;
23
+ }
24
+
25
+ /** Thrown by {@link Schema.parse}. Its message names the first failure. */
26
+ export class ValidationError extends Error {
27
+ readonly issues: readonly Issue[];
28
+
29
+ constructor(issues: readonly Issue[]) {
30
+ const first = issues[0];
31
+ const where = first && first.path.length > 0 ? formatPath(first.path) : 'value';
32
+ super(first ? `${where}: ${first.message}` : 'invalid value');
33
+ this.name = 'ValidationError';
34
+ this.issues = issues;
35
+ }
36
+ }
37
+
38
+ function formatPath(path: IssuePath): string {
39
+ let out = '';
40
+ for (const segment of path) {
41
+ if (typeof segment === 'number') out += `[${String(segment)}]`;
42
+ else out += out === '' ? segment : `.${segment}`;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** The result of a non-throwing validation. */
48
+ export type Result<T> =
49
+ | { readonly ok: true; readonly value: T }
50
+ | { readonly ok: false; readonly issues: readonly Issue[] };
51
+
52
+ /** A runtime schema for one JSON value. */
53
+ export interface Schema<T> {
54
+ /** Discriminates a Broapp schema from a foreign one at runtime. */
55
+ readonly kind: string;
56
+ /** Validate without throwing. */
57
+ check(value: unknown, path?: IssuePath): Result<T>;
58
+ /** Validate, or throw {@link ValidationError}. */
59
+ parse(value: unknown): T;
60
+ /** Phantom marker; never present at runtime. */
61
+ readonly _type?: T;
62
+ }
63
+
64
+ /** The TypeScript type a schema accepts. */
65
+ export type Infer<S> = S extends Schema<infer T> ? T : never;
66
+
67
+ function schema<T>(kind: string, check: (value: unknown, path: IssuePath) => Result<T>): Schema<T> {
68
+ const self: Schema<T> = {
69
+ kind,
70
+ check: (value, path = []) => check(value, path),
71
+ parse(value) {
72
+ const outcome = self.check(value, []);
73
+ if (outcome.ok) return outcome.value;
74
+ throw new ValidationError(outcome.issues);
75
+ },
76
+ };
77
+ return self;
78
+ }
79
+
80
+ function fail<T = never>(path: IssuePath, message: string): Result<T> {
81
+ return { ok: false, issues: [{ path, message }] };
82
+ }
83
+
84
+ /** Options for {@link s.string}. */
85
+ export interface StringOptions {
86
+ readonly min?: number;
87
+ readonly max?: number;
88
+ /** Must match end to end. Anchors are added, so a partial pattern still means "the whole string". */
89
+ readonly pattern?: RegExp;
90
+ }
91
+
92
+ /** Options for {@link s.number}. */
93
+ export interface NumberOptions {
94
+ readonly min?: number;
95
+ readonly max?: number;
96
+ readonly int?: boolean;
97
+ }
98
+
99
+ /** Options for {@link s.array}. */
100
+ export interface ArrayOptions {
101
+ readonly min?: number;
102
+ readonly max?: number;
103
+ }
104
+
105
+ /** Schema constructors. */
106
+ export const s = {
107
+ string(options: StringOptions = {}): Schema<string> {
108
+ return schema('string', (value, path) => {
109
+ if (typeof value !== 'string') return fail(path, 'expected a string');
110
+ if (options.min !== undefined && value.length < options.min) {
111
+ return fail(path, `expected at least ${String(options.min)} character(s)`);
112
+ }
113
+ if (options.max !== undefined && value.length > options.max) {
114
+ return fail(path, `expected at most ${String(options.max)} character(s)`);
115
+ }
116
+ if (options.pattern !== undefined) {
117
+ const anchored = new RegExp(`^(?:${options.pattern.source})$`, options.pattern.flags.replace('g', ''));
118
+ if (!anchored.test(value)) return fail(path, 'does not match the required format');
119
+ }
120
+ return { ok: true, value };
121
+ });
122
+ },
123
+
124
+ number(options: NumberOptions = {}): Schema<number> {
125
+ return schema('number', (value, path) => {
126
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
127
+ return fail(path, 'expected a finite number');
128
+ }
129
+ if (options.int === true && !Number.isInteger(value)) return fail(path, 'expected an integer');
130
+ if (options.min !== undefined && value < options.min) {
131
+ return fail(path, `expected >= ${String(options.min)}`);
132
+ }
133
+ if (options.max !== undefined && value > options.max) {
134
+ return fail(path, `expected <= ${String(options.max)}`);
135
+ }
136
+ return { ok: true, value };
137
+ });
138
+ },
139
+
140
+ boolean(): Schema<boolean> {
141
+ return schema('boolean', (value, path) =>
142
+ typeof value === 'boolean' ? { ok: true, value } : fail(path, 'expected a boolean'),
143
+ );
144
+ },
145
+
146
+ literal<const T extends string | number | boolean>(expected: T): Schema<T> {
147
+ return schema('literal', (value, path) =>
148
+ value === expected
149
+ ? { ok: true, value: expected }
150
+ : fail(path, `expected ${JSON.stringify(expected)}`),
151
+ );
152
+ },
153
+
154
+ /** A closed set of string values. */
155
+ enum<const T extends readonly string[]>(values: T): Schema<T[number]> {
156
+ const allowed = new Set<string>(values);
157
+ return schema('enum', (value, path) =>
158
+ typeof value === 'string' && allowed.has(value)
159
+ ? { ok: true, value: value as T[number] }
160
+ : fail(path, `expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}`),
161
+ );
162
+ },
163
+
164
+ array<T>(item: Schema<T>, options: ArrayOptions = {}): Schema<T[]> {
165
+ return schema('array', (value, path) => {
166
+ if (!Array.isArray(value)) return fail(path, 'expected an array');
167
+ if (options.min !== undefined && value.length < options.min) {
168
+ return fail(path, `expected at least ${String(options.min)} item(s)`);
169
+ }
170
+ if (options.max !== undefined && value.length > options.max) {
171
+ return fail(path, `expected at most ${String(options.max)} item(s)`);
172
+ }
173
+ const out: T[] = [];
174
+ const issues: Issue[] = [];
175
+ for (let index = 0; index < value.length; index += 1) {
176
+ const outcome = item.check(value[index], [...path, index]);
177
+ if (outcome.ok) out.push(outcome.value);
178
+ else issues.push(...outcome.issues);
179
+ }
180
+ return issues.length > 0 ? { ok: false, issues } : { ok: true, value: out };
181
+ });
182
+ },
183
+
184
+ /**
185
+ * An object with a fixed set of keys.
186
+ *
187
+ * Unknown keys are dropped rather than rejected: the value that reaches a
188
+ * handler contains only what the schema named, so a property smuggled in by
189
+ * a caller cannot reach application code by accident.
190
+ */
191
+ object<F extends Record<string, Schema<unknown>>>(
192
+ fields: F,
193
+ ): Schema<{ [K in keyof F]: Infer<F[K]> }> {
194
+ const entries = Object.entries(fields);
195
+ return schema('object', (value, path) => {
196
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
197
+ return fail(path, 'expected an object');
198
+ }
199
+ const source = value as Record<string, unknown>;
200
+ const out: Record<string, unknown> = {};
201
+ const issues: Issue[] = [];
202
+ for (const [key, field] of entries) {
203
+ if (!(key in source) && field.kind === 'optional') continue;
204
+ const outcome = field.check(source[key], [...path, key]);
205
+ if (outcome.ok) {
206
+ if (outcome.value !== undefined || key in source) out[key] = outcome.value;
207
+ } else issues.push(...outcome.issues);
208
+ }
209
+ return issues.length > 0
210
+ ? { ok: false, issues }
211
+ : { ok: true, value: out as { [K in keyof F]: Infer<F[K]> } };
212
+ });
213
+ },
214
+
215
+ /** A value that may be absent or `undefined`. */
216
+ optional<T>(inner: Schema<T>): Schema<T | undefined> {
217
+ return schema<T | undefined>('optional', (value, path) =>
218
+ value === undefined ? { ok: true, value: undefined } : inner.check(value, path),
219
+ );
220
+ },
221
+
222
+ /** A value that may be `null`. */
223
+ nullable<T>(inner: Schema<T>): Schema<T | null> {
224
+ return schema<T | null>('nullable', (value, path) =>
225
+ value === null ? { ok: true, value: null } : inner.check(value, path),
226
+ );
227
+ },
228
+
229
+ /** Nothing at all. The input type of an operation that takes no argument. */
230
+ void(): Schema<void> {
231
+ return schema('void', (value, path) =>
232
+ value === undefined || value === null
233
+ ? { ok: true, value: undefined }
234
+ : fail(path, 'expected no value'),
235
+ );
236
+ },
237
+
238
+ /**
239
+ * Any JSON value, unchecked.
240
+ *
241
+ * Use it for an output whose shape the host controls. Do not use it for an
242
+ * operation input: the point of the input schema is that browser-supplied
243
+ * data is untrusted.
244
+ */
245
+ unknown(): Schema<unknown> {
246
+ return schema('unknown', (value) => ({ ok: true, value }));
247
+ },
248
+ } as const;