@homespunapps/core 1.0.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 +31 -0
- package/dist/app-stream.d.ts +68 -0
- package/dist/app-stream.js +131 -0
- package/dist/client.d.ts +508 -0
- package/dist/client.js +619 -0
- package/dist/icons.d.ts +24 -0
- package/dist/icons.js +99 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +9 -0
- package/dist/limits.d.ts +8 -0
- package/dist/limits.js +10 -0
- package/dist/register.d.ts +32 -0
- package/dist/register.js +65 -0
- package/dist/schemas.d.ts +15 -0
- package/dist/schemas.js +24 -0
- package/dist/stream.d.ts +70 -0
- package/dist/stream.js +113 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +7 -0
- package/package.json +58 -0
package/dist/icons.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Icon helpers shared by the relay and CLI — emoji validation and the raster
|
|
2
|
+
// image MIME allowlist for template/app icons.
|
|
3
|
+
//
|
|
4
|
+
// Icons are deliberately constrained: either ONE emoji grapheme (rendered
|
|
5
|
+
// inline as text) or an uploaded raster image (served from the relay's blob
|
|
6
|
+
// store). No external URLs, no SVG (XSS vector), no multi-character strings.
|
|
7
|
+
// Raster image MIME types accepted as an uploaded icon. SVG is deliberately
|
|
8
|
+
// EXCLUDED — it can carry script and is an XSS vector when rendered in an
|
|
9
|
+
// <img> from a same-origin route. Vector/animated-vector and non-image types
|
|
10
|
+
// are rejected.
|
|
11
|
+
export const RASTER_ICON_MIME_ALLOWLIST = [
|
|
12
|
+
"image/png",
|
|
13
|
+
"image/jpeg",
|
|
14
|
+
"image/webp",
|
|
15
|
+
"image/gif",
|
|
16
|
+
];
|
|
17
|
+
/** True iff `mime` is a raster image type allowed as an icon. */
|
|
18
|
+
export function isRasterImageMime(mime) {
|
|
19
|
+
if (!mime)
|
|
20
|
+
return false;
|
|
21
|
+
// Normalise: drop any `; charset=...` parameter and lowercase.
|
|
22
|
+
const base = mime.split(";")[0].trim().toLowerCase();
|
|
23
|
+
return RASTER_ICON_MIME_ALLOWLIST.includes(base);
|
|
24
|
+
}
|
|
25
|
+
// Upper bound on the raw byte length of an emoji string. A single rendered
|
|
26
|
+
// emoji can be a long ZWJ sequence (e.g. a family/flag with skin-tone
|
|
27
|
+
// modifiers), but 64 bytes is well beyond any real single grapheme and bounds
|
|
28
|
+
// the work the segmenter does.
|
|
29
|
+
export const MAX_ICON_EMOJI_BYTES = 64;
|
|
30
|
+
// Matches a code point with the Extended_Pictographic property — the Unicode
|
|
31
|
+
// property that flags "this is an emoji-ish pictograph". Used to ensure the
|
|
32
|
+
// grapheme actually contains an emoji and isn't a plain letter/digit/symbol.
|
|
33
|
+
const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
|
|
34
|
+
// Control characters (C0 + DEL + C1) are never valid in an icon emoji. The
|
|
35
|
+
// control chars in the class are intentional — that's exactly what we reject.
|
|
36
|
+
// eslint-disable-next-line no-control-regex
|
|
37
|
+
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]/;
|
|
38
|
+
const byteLength = (s) => typeof TextEncoder !== "undefined"
|
|
39
|
+
? new TextEncoder().encode(s).length
|
|
40
|
+
: Buffer.byteLength(s, "utf8");
|
|
41
|
+
/**
|
|
42
|
+
* Validate an icon emoji. Returns `{ ok: true }` for a single emoji grapheme,
|
|
43
|
+
* or `{ ok: false, error }` with a human-readable reason otherwise.
|
|
44
|
+
*
|
|
45
|
+
* Rules:
|
|
46
|
+
* - non-empty, ≤ MAX_ICON_EMOJI_BYTES bytes
|
|
47
|
+
* - no control characters
|
|
48
|
+
* - exactly ONE grapheme cluster (via Intl.Segmenter)
|
|
49
|
+
* - that grapheme contains at least one Extended_Pictographic code point
|
|
50
|
+
* (so plain ASCII letters/digits and bare symbols are rejected)
|
|
51
|
+
*/
|
|
52
|
+
export function validateIconEmoji(raw) {
|
|
53
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
54
|
+
return { ok: false, error: "icon_emoji must be a non-empty string" };
|
|
55
|
+
}
|
|
56
|
+
if (byteLength(raw) > MAX_ICON_EMOJI_BYTES) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: `icon_emoji must be at most ${MAX_ICON_EMOJI_BYTES} bytes`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (CONTROL_CHAR_RE.test(raw)) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: "icon_emoji must not contain control characters",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Count grapheme clusters. Intl.Segmenter is available in Node 20+ and all
|
|
69
|
+
// evergreen browsers (our targets); guard defensively anyway.
|
|
70
|
+
let graphemeCount;
|
|
71
|
+
if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
|
|
72
|
+
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
73
|
+
// Iterate the segmenter to count grapheme clusters. `[...iterable].length`
|
|
74
|
+
// materialises the segments, but a single emoji is tiny so it's fine.
|
|
75
|
+
graphemeCount = [...seg.segment(raw)].length;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Fallback: count code points (over-counts ZWJ sequences, but the
|
|
79
|
+
// pictographic check below still gates non-emoji input).
|
|
80
|
+
graphemeCount = Array.from(raw).length;
|
|
81
|
+
}
|
|
82
|
+
if (graphemeCount !== 1) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: "icon_emoji must be exactly one emoji (a single grapheme)",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!EXTENDED_PICTOGRAPHIC_RE.test(raw)) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: "icon_emoji must be an emoji, not a letter, digit, or symbol",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { ok: true };
|
|
95
|
+
}
|
|
96
|
+
/** Convenience boolean form of {@link validateIconEmoji}. */
|
|
97
|
+
export function isValidIconEmoji(raw) {
|
|
98
|
+
return validateIconEmoji(raw).ok;
|
|
99
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { HomespunClient, HomespunApiError } from "./client.js";
|
|
2
|
+
export type { ClientOptions, RelayResponse, AttachmentRef, UploadBlobOptions, PresignBlobOptions, AttachmentTokenMintResponse, ListBlobsOptions, AttachmentTokenAuditEntry, AttachmentTokenListResponse, QueryResponse, DeployAppRequest, DeployAppResponse, RedeployAppRequest, RedeployAppResponse, AppSummary, AppDetail, AppsPage, AppMember, AddAppMemberResult, AppRow, AppRowsPage, AppFeedEntry, AppFeedPage, } from "./client.js";
|
|
3
|
+
export { openStream } from "./stream.js";
|
|
4
|
+
export type { OpenStreamOptions, StreamHandlers, StreamHandle, } from "./stream.js";
|
|
5
|
+
export { openAppStream, appWsUrlFromAppUrl } from "./app-stream.js";
|
|
6
|
+
export type { OpenAppStreamOptions, AppStreamHandlers, AppStreamHandle, } from "./app-stream.js";
|
|
7
|
+
export { registerAgent } from "./register.js";
|
|
8
|
+
export type { RegisterAgentOptions, RegisterAgentResult } from "./register.js";
|
|
9
|
+
export { feedbackTypeSchema, submitFeedbackSchema } from "./schemas.js";
|
|
10
|
+
export { validateIconEmoji, isValidIconEmoji, isRasterImageMime, RASTER_ICON_MIME_ALLOWLIST, MAX_ICON_EMOJI_BYTES, } from "./icons.js";
|
|
11
|
+
export type { RasterIconMime } from "./icons.js";
|
|
12
|
+
export { MAX_EVENT_TYPE_LENGTH, MAX_IDEMPOTENCY_KEY_LENGTH, MAX_RESPONSE_SNIPPET_LENGTH, MAX_FRAME_SNIPPET_LENGTH, } from "./limits.js";
|
|
13
|
+
export type { AuthorKind, HomespunEvent, SerializedRecord, DeletedRecordRef, RecordDeltaMessage, Template, KeyInfo, TasteInfo, FeedbackType, FeedbackSubmission, FeedbackRecord, FeedbackPage, Callback, RelayError, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @homespunapps/core — typed client for the Homespun relay HTTP + WebSocket API.
|
|
2
|
+
// Pure and framework-free: no argv, no MCP, no server deps.
|
|
3
|
+
export { HomespunClient, HomespunApiError } from "./client.js";
|
|
4
|
+
export { openStream } from "./stream.js";
|
|
5
|
+
export { openAppStream, appWsUrlFromAppUrl } from "./app-stream.js";
|
|
6
|
+
export { registerAgent } from "./register.js";
|
|
7
|
+
export { feedbackTypeSchema, submitFeedbackSchema } from "./schemas.js";
|
|
8
|
+
export { validateIconEmoji, isValidIconEmoji, isRasterImageMime, RASTER_ICON_MIME_ALLOWLIST, MAX_ICON_EMOJI_BYTES, } from "./icons.js";
|
|
9
|
+
export { MAX_EVENT_TYPE_LENGTH, MAX_IDEMPOTENCY_KEY_LENGTH, MAX_RESPONSE_SNIPPET_LENGTH, MAX_FRAME_SNIPPET_LENGTH, } from "./limits.js";
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Maximum length of an event type string, in characters. */
|
|
2
|
+
export declare const MAX_EVENT_TYPE_LENGTH = 64;
|
|
3
|
+
/** Maximum length of an idempotency key string, in characters. */
|
|
4
|
+
export declare const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
|
5
|
+
/** Maximum number of characters from a raw response body to include in error details. */
|
|
6
|
+
export declare const MAX_RESPONSE_SNIPPET_LENGTH = 500;
|
|
7
|
+
/** Maximum number of characters from a raw stream frame to include in error messages. */
|
|
8
|
+
export declare const MAX_FRAME_SNIPPET_LENGTH = 200;
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Shared protocol limits used across transports (HTTP and WebSocket).
|
|
2
|
+
// Defined once here so both relay and client code import the same constants.
|
|
3
|
+
/** Maximum length of an event type string, in characters. */
|
|
4
|
+
export const MAX_EVENT_TYPE_LENGTH = 64;
|
|
5
|
+
/** Maximum length of an idempotency key string, in characters. */
|
|
6
|
+
export const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
|
7
|
+
/** Maximum number of characters from a raw response body to include in error details. */
|
|
8
|
+
export const MAX_RESPONSE_SNIPPET_LENGTH = 500;
|
|
9
|
+
/** Maximum number of characters from a raw stream frame to include in error messages. */
|
|
10
|
+
export const MAX_FRAME_SNIPPET_LENGTH = 200;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface RegisterAgentOptions {
|
|
2
|
+
/** Relay base URL, e.g. https://homespun.example.com. Trailing slash is trimmed. */
|
|
3
|
+
url: string;
|
|
4
|
+
/** Optional agent display name; the relay defaults it if omitted. */
|
|
5
|
+
name?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Shared registration secret. Sent as `Authorization: Bearer <secret>`.
|
|
8
|
+
* Only needed when the relay runs REGISTRATION_MODE=secret; ignored by
|
|
9
|
+
* relays in open mode and rejected (404) by relays in closed mode.
|
|
10
|
+
*/
|
|
11
|
+
secret?: string;
|
|
12
|
+
/** Optional fetch override (defaults to global fetch). */
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
/**
|
|
15
|
+
* Optional client version string. Sent as `x-homespun-cli-version` so the
|
|
16
|
+
* relay's version-skew check can flag a too-old CLI even on the
|
|
17
|
+
* un-authenticated register endpoint. See `HomespunClient`'s `cliVersion`
|
|
18
|
+
* option for the parent contract.
|
|
19
|
+
*/
|
|
20
|
+
cliVersion?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RegisterAgentResult {
|
|
23
|
+
agent_id: string;
|
|
24
|
+
api_key: string;
|
|
25
|
+
key_prefix: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Provision a fresh agent + API key from the relay. Mirrors HomespunClient.call's
|
|
29
|
+
* never-throw-raw style: network/parse failures and non-2xx responses are
|
|
30
|
+
* surfaced as HomespunApiError.
|
|
31
|
+
*/
|
|
32
|
+
export declare function registerAgent(opts: RegisterAgentOptions): Promise<RegisterAgentResult>;
|
package/dist/register.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Standalone agent self-registration: POST /v1/register.
|
|
2
|
+
//
|
|
3
|
+
// Unlike HomespunClient operations this needs no bearer API key — registration is
|
|
4
|
+
// the call that *obtains* one. Whether the relay endpoint is reachable depends
|
|
5
|
+
// on its REGISTRATION_MODE: a `secret`-mode relay requires the shared
|
|
6
|
+
// registration secret to be passed as a Bearer token (see the `secret` option
|
|
7
|
+
// below). Abuse is bounded server-side by a per-IP rate limit (a 429 apps
|
|
8
|
+
// here as a HomespunApiError with status 429).
|
|
9
|
+
import { HomespunApiError } from "./client.js";
|
|
10
|
+
import { MAX_RESPONSE_SNIPPET_LENGTH } from "./limits.js";
|
|
11
|
+
/**
|
|
12
|
+
* Provision a fresh agent + API key from the relay. Mirrors HomespunClient.call's
|
|
13
|
+
* never-throw-raw style: network/parse failures and non-2xx responses are
|
|
14
|
+
* surfaced as HomespunApiError.
|
|
15
|
+
*/
|
|
16
|
+
export async function registerAgent(opts) {
|
|
17
|
+
const base = opts.url.replace(/\/$/, "");
|
|
18
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
19
|
+
const body = {};
|
|
20
|
+
if (opts.name !== undefined)
|
|
21
|
+
body["name"] = opts.name;
|
|
22
|
+
const headers = {
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
};
|
|
25
|
+
// Sent only when the relay runs REGISTRATION_MODE=secret; harmless otherwise.
|
|
26
|
+
if (opts.secret !== undefined && opts.secret !== "") {
|
|
27
|
+
headers["authorization"] = `Bearer ${opts.secret}`;
|
|
28
|
+
}
|
|
29
|
+
if (opts.cliVersion !== undefined && opts.cliVersion !== "") {
|
|
30
|
+
headers["x-homespun-cli-version"] = opts.cliVersion;
|
|
31
|
+
}
|
|
32
|
+
let res;
|
|
33
|
+
try {
|
|
34
|
+
res = await fetchImpl(base + "/v1/register", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers,
|
|
37
|
+
body: JSON.stringify(body),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
42
|
+
throw new HomespunApiError(0, "fetch_error", msg);
|
|
43
|
+
}
|
|
44
|
+
let data = null;
|
|
45
|
+
const text = await res.text().catch(() => "");
|
|
46
|
+
if (text !== "") {
|
|
47
|
+
try {
|
|
48
|
+
data = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
const snippet = text.length > MAX_RESPONSE_SNIPPET_LENGTH
|
|
52
|
+
? text.slice(0, MAX_RESPONSE_SNIPPET_LENGTH) + "…"
|
|
53
|
+
: text;
|
|
54
|
+
throw new HomespunApiError(res.status, "non_json_response", `relay returned a non-JSON body (status ${res.status})`, { body: snippet });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const err = data?.error;
|
|
59
|
+
throw new HomespunApiError(res.status, err?.code ?? "relay_error", err?.message ?? `relay returned ${res.status}`, err?.details);
|
|
60
|
+
}
|
|
61
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
62
|
+
throw new HomespunApiError(res.status, "invalid_response", `relay returned a ${res.status} with a non-object body`, { body: data });
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const feedbackTypeSchema: z.ZodEnum<{
|
|
3
|
+
bug: "bug";
|
|
4
|
+
feature: "feature";
|
|
5
|
+
note: "note";
|
|
6
|
+
}>;
|
|
7
|
+
export declare const submitFeedbackSchema: z.ZodObject<{
|
|
8
|
+
type: z.ZodEnum<{
|
|
9
|
+
bug: "bug";
|
|
10
|
+
feature: "feature";
|
|
11
|
+
note: "note";
|
|
12
|
+
}>;
|
|
13
|
+
message: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>;
|
|
14
|
+
app_id: z.ZodOptional<z.ZodString>;
|
|
15
|
+
}, z.core.$strip>;
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Zod schemas for the Homespun relay request shapes. These let callers (the CLI,
|
|
2
|
+
// other clients) validate user-supplied input before it hits the relay,
|
|
3
|
+
// producing clear errors.
|
|
4
|
+
//
|
|
5
|
+
// The legacy v1 request schemas (create / list / upgrade / update /
|
|
6
|
+
// mint-participant, plus their inline + reference template forms) were removed
|
|
7
|
+
// when the v1 data model was dropped: they had no surviving HomespunClient
|
|
8
|
+
// method and no live relay route. Only the feedback schema, still used by the
|
|
9
|
+
// CLI + MCP, remains.
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
// POST /v1/feedback — an agent submits a bug report, feature request, or note
|
|
12
|
+
// to the relay operator. Message is trimmed before length check so whitespace
|
|
13
|
+
// padding cannot bypass the 1..4000 cap.
|
|
14
|
+
export const feedbackTypeSchema = z.enum(["bug", "feature", "note"]);
|
|
15
|
+
export const submitFeedbackSchema = z.object({
|
|
16
|
+
type: feedbackTypeSchema,
|
|
17
|
+
message: z
|
|
18
|
+
.string()
|
|
19
|
+
.transform((s) => s.trim())
|
|
20
|
+
.pipe(z.string().min(1).max(4000)),
|
|
21
|
+
// App-scoped (v2): optionally attach the feedback to an App the caller can
|
|
22
|
+
// access.
|
|
23
|
+
app_id: z.string().min(1).optional(),
|
|
24
|
+
});
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import type { HomespunEvent, RecordDeltaMessage } from "./types.js";
|
|
3
|
+
export interface OpenStreamOptions {
|
|
4
|
+
/** WebSocket base URL, e.g. wss://homespun.example.com (no trailing slash). */
|
|
5
|
+
wsBaseUrl: string;
|
|
6
|
+
/** Homespun id. */
|
|
7
|
+
appId: string;
|
|
8
|
+
/** Agent (or participant) bearer token. */
|
|
9
|
+
token: string;
|
|
10
|
+
/** Opaque cursor: replay only events strictly after this id. */
|
|
11
|
+
since?: string | null;
|
|
12
|
+
/**
|
|
13
|
+
* #297 — subscribe to record-collection deltas. `"*"` expands to every
|
|
14
|
+
* declared collection on the app's template; a comma list filters
|
|
15
|
+
* to those names. Absent = no record traffic (legacy event-only stream).
|
|
16
|
+
*/
|
|
17
|
+
subscribeRecords?: string;
|
|
18
|
+
/**
|
|
19
|
+
* #297 — per-collection record-replay cursors. Map of collection name →
|
|
20
|
+
* last observed seq, sent as `?since_record_seq.<name>=<seq>` so the
|
|
21
|
+
* relay's replay (#295) skips already-observed rows on reconnect.
|
|
22
|
+
*/
|
|
23
|
+
sinceRecordSeq?: Record<string, number>;
|
|
24
|
+
}
|
|
25
|
+
/** Callbacks for a live stream. */
|
|
26
|
+
export interface StreamHandlers {
|
|
27
|
+
/** Fired for every event envelope (replayed and live). */
|
|
28
|
+
onEvent?: (event: HomespunEvent) => void;
|
|
29
|
+
/** Fired once when the initial event replay finishes. */
|
|
30
|
+
onReplayComplete?: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* #297 — fired for every record-delta message (record.upsert /
|
|
33
|
+
* record.delete / record.replay.complete) on the stream. Per-collection
|
|
34
|
+
* record.replay.complete fires once per subscribed collection after
|
|
35
|
+
* the replay set has been drained.
|
|
36
|
+
*/
|
|
37
|
+
onRecord?: (msg: RecordDeltaMessage) => void;
|
|
38
|
+
/** Fired on a relay error frame. */
|
|
39
|
+
onRelayError?: (error: {
|
|
40
|
+
code?: string;
|
|
41
|
+
message?: string;
|
|
42
|
+
details?: unknown;
|
|
43
|
+
}) => void;
|
|
44
|
+
/** Fired when the socket closes (cleanly or otherwise). */
|
|
45
|
+
onClose?: (info: {
|
|
46
|
+
code: number;
|
|
47
|
+
reason: string;
|
|
48
|
+
}) => void;
|
|
49
|
+
/** Fired on a transport-level error. */
|
|
50
|
+
onError?: (err: Error) => void;
|
|
51
|
+
}
|
|
52
|
+
/** A live handle to an open stream. */
|
|
53
|
+
export interface StreamHandle {
|
|
54
|
+
/** Send an event frame into the app. */
|
|
55
|
+
send(frame: {
|
|
56
|
+
type: string;
|
|
57
|
+
data?: unknown;
|
|
58
|
+
causation_id?: string;
|
|
59
|
+
idempotency_key?: string;
|
|
60
|
+
}): void;
|
|
61
|
+
/** Close the stream. */
|
|
62
|
+
close(): void;
|
|
63
|
+
/** The underlying ws socket (escape hatch). */
|
|
64
|
+
readonly socket: WebSocket;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Open a WebSocket stream to a Homespun app. Replays on connect, then streams
|
|
68
|
+
* live. Returns a handle for sending frames and closing.
|
|
69
|
+
*/
|
|
70
|
+
export declare function openStream(opts: OpenStreamOptions, handlers: StreamHandlers): StreamHandle;
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// WebSocket client for WS /v1/apps/:id/stream.
|
|
2
|
+
//
|
|
3
|
+
// The relay protocol (see the relay's src/ws/handler.ts):
|
|
4
|
+
// - on connect, the relay replays every event since `?since=` (or from the
|
|
5
|
+
// start), then sends a `{ kind: "system.replay.complete" }` marker;
|
|
6
|
+
// - thereafter it pushes live events as they land;
|
|
7
|
+
// - each frame is a JSON object: either a HomespunEvent envelope, the replay
|
|
8
|
+
// marker, an `{ ack, deduped }` for frames we sent, or an `{ error }`.
|
|
9
|
+
//
|
|
10
|
+
// Note: `system.participant.joined` / `system.participant.left` (and other
|
|
11
|
+
// `system.*` events) arrive as ordinary `HomespunEvent` envelopes — they are not a
|
|
12
|
+
// distinct frame kind, and may be interleaved with the initial replay stream
|
|
13
|
+
// just like any other event.
|
|
14
|
+
//
|
|
15
|
+
// `openStream` exposes this as a typed event emitter over the `ws` package.
|
|
16
|
+
import { WebSocket } from "ws";
|
|
17
|
+
import { MAX_FRAME_SNIPPET_LENGTH } from "./limits.js";
|
|
18
|
+
/**
|
|
19
|
+
* Open a WebSocket stream to a Homespun app. Replays on connect, then streams
|
|
20
|
+
* live. Returns a handle for sending frames and closing.
|
|
21
|
+
*/
|
|
22
|
+
export function openStream(opts, handlers) {
|
|
23
|
+
const base = opts.wsBaseUrl.replace(/\/$/, "");
|
|
24
|
+
const u = new URL(`${base}/v1/apps/${encodeURIComponent(opts.appId)}/stream`);
|
|
25
|
+
if (opts.since != null && opts.since !== "") {
|
|
26
|
+
u.searchParams.set("since", opts.since);
|
|
27
|
+
}
|
|
28
|
+
if (opts.subscribeRecords && opts.subscribeRecords.length > 0) {
|
|
29
|
+
u.searchParams.set("subscribe_records", opts.subscribeRecords);
|
|
30
|
+
}
|
|
31
|
+
if (opts.sinceRecordSeq) {
|
|
32
|
+
for (const [name, seq] of Object.entries(opts.sinceRecordSeq)) {
|
|
33
|
+
u.searchParams.set(`since_record_seq.${name}`, String(seq));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Token via Authorization header (Node ws supports it); the relay also
|
|
37
|
+
// accepts ?token= but the header keeps it out of any URL access log.
|
|
38
|
+
const socket = new WebSocket(u.toString(), {
|
|
39
|
+
headers: { authorization: "Bearer " + opts.token },
|
|
40
|
+
});
|
|
41
|
+
socket.on("message", (raw) => {
|
|
42
|
+
const text = raw.toString();
|
|
43
|
+
let msg;
|
|
44
|
+
try {
|
|
45
|
+
msg = JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
// A malformed frame must never be silently dropped — a dropped event
|
|
49
|
+
// makes `watch --type X` hang forever. Surface it as a transport error.
|
|
50
|
+
const snippet = text.length > MAX_FRAME_SNIPPET_LENGTH
|
|
51
|
+
? text.slice(0, MAX_FRAME_SNIPPET_LENGTH) + "…"
|
|
52
|
+
: text;
|
|
53
|
+
handlers.onError?.(new Error(`failed to parse stream frame as JSON (${e instanceof Error ? e.message : String(e)}): ${snippet}`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!msg || typeof msg !== "object") {
|
|
57
|
+
handlers.onError?.(new Error(`unexpected non-object stream frame: ${text.slice(0, MAX_FRAME_SNIPPET_LENGTH)}`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const obj = msg;
|
|
61
|
+
if (obj["kind"] === "system.replay.complete") {
|
|
62
|
+
handlers.onReplayComplete?.();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if ("error" in obj) {
|
|
66
|
+
handlers.onRelayError?.(obj["error"]);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if ("ack" in obj) {
|
|
70
|
+
// Ack for a frame we sent; nothing to app by default.
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// #297 — record deltas. record.upsert / record.delete / record.replay.complete.
|
|
74
|
+
const kind = obj["kind"];
|
|
75
|
+
if (kind === "record.upsert" ||
|
|
76
|
+
kind === "record.delete" ||
|
|
77
|
+
kind === "record.replay.complete") {
|
|
78
|
+
handlers.onRecord?.(obj);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (typeof obj["id"] === "string" && typeof obj["type"] === "string") {
|
|
82
|
+
handlers.onEvent?.(obj);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Unrecognized frame shape — route to onError rather than dropping it.
|
|
86
|
+
handlers.onError?.(new Error(`unrecognized stream frame: ${JSON.stringify(obj).slice(0, MAX_FRAME_SNIPPET_LENGTH)}`));
|
|
87
|
+
});
|
|
88
|
+
socket.on("close", (code, reason) => {
|
|
89
|
+
handlers.onClose?.({ code, reason: reason.toString() });
|
|
90
|
+
});
|
|
91
|
+
socket.on("error", (err) => {
|
|
92
|
+
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
send(frame) {
|
|
96
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
97
|
+
throw new Error(`cannot send frame: stream socket is not open (readyState=${socket.readyState})`);
|
|
98
|
+
}
|
|
99
|
+
socket.send(JSON.stringify(frame));
|
|
100
|
+
},
|
|
101
|
+
close() {
|
|
102
|
+
try {
|
|
103
|
+
socket.close();
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
console.debug("[app] stream close error:", e instanceof Error ? e.message : String(e));
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
get socket() {
|
|
110
|
+
return socket;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type AuthorKind = "human" | "agent" | "system";
|
|
2
|
+
/** A single event envelope as emitted by the relay. */
|
|
3
|
+
export interface HomespunEvent {
|
|
4
|
+
id: string;
|
|
5
|
+
app_id: string;
|
|
6
|
+
author: {
|
|
7
|
+
kind: AuthorKind;
|
|
8
|
+
id: string;
|
|
9
|
+
};
|
|
10
|
+
ts: string;
|
|
11
|
+
type: string;
|
|
12
|
+
data: unknown;
|
|
13
|
+
causation_id: string | null;
|
|
14
|
+
idempotency_key: string | null;
|
|
15
|
+
/**
|
|
16
|
+
* The template version this event was written under — the app's pinned
|
|
17
|
+
* templateVersionId at the moment of the write. Stamped at write time and
|
|
18
|
+
* never rewritten, so a downstream upgrade (#267) can read old events
|
|
19
|
+
* under the new schema (Level 1 polymorphic render). Nullable for events
|
|
20
|
+
* written before #268 landed; the relay's one-shot migration backfilled
|
|
21
|
+
* those from the app's current pin where possible.
|
|
22
|
+
*/
|
|
23
|
+
template_version_id: string | null;
|
|
24
|
+
/** Denormalised integer version number for `template_version_id`. */
|
|
25
|
+
template_version: number | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One record on the wire (#287). Returned by the records CRUD routes
|
|
29
|
+
* (#292) and by the WS record-delta messages (#294). Structurally
|
|
30
|
+
* identical to the relay-side SerializedRecord; mirrored here so the
|
|
31
|
+
* core package is self-contained.
|
|
32
|
+
*/
|
|
33
|
+
export interface SerializedRecord {
|
|
34
|
+
id: string;
|
|
35
|
+
collection: string;
|
|
36
|
+
key: string;
|
|
37
|
+
data: unknown;
|
|
38
|
+
version: number;
|
|
39
|
+
seq: number;
|
|
40
|
+
author: {
|
|
41
|
+
kind: AuthorKind;
|
|
42
|
+
id: string;
|
|
43
|
+
};
|
|
44
|
+
created_at: string;
|
|
45
|
+
updated_at: string;
|
|
46
|
+
deleted_at: string | null;
|
|
47
|
+
}
|
|
48
|
+
/** Wire shape for a soft-deleted record on the WS channel. */
|
|
49
|
+
export interface DeletedRecordRef {
|
|
50
|
+
id: string;
|
|
51
|
+
key: string;
|
|
52
|
+
seq: number;
|
|
53
|
+
deleted_at: string;
|
|
54
|
+
}
|
|
55
|
+
/** Discriminated wire shape for record-state changes. */
|
|
56
|
+
export type RecordDeltaMessage = {
|
|
57
|
+
kind: "record.upsert";
|
|
58
|
+
collection: string;
|
|
59
|
+
record: SerializedRecord;
|
|
60
|
+
} | {
|
|
61
|
+
kind: "record.delete";
|
|
62
|
+
collection: string;
|
|
63
|
+
record: DeletedRecordRef;
|
|
64
|
+
} | {
|
|
65
|
+
kind: "record.replay.complete";
|
|
66
|
+
collection: string;
|
|
67
|
+
seq: number;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* An template: discriminated on `type`. `html-inline` carries raw HTML in
|
|
71
|
+
* `source`; `html-ref` carries a URL the relay/shell fetches on the human's
|
|
72
|
+
* behalf. The discriminant keeps the type↔source coupling explicit.
|
|
73
|
+
*/
|
|
74
|
+
export type Template = {
|
|
75
|
+
type: "html-inline";
|
|
76
|
+
source: string;
|
|
77
|
+
} | {
|
|
78
|
+
type: "html-ref";
|
|
79
|
+
source: string;
|
|
80
|
+
};
|
|
81
|
+
/** Optional webhook callback config. */
|
|
82
|
+
export interface Callback {
|
|
83
|
+
url: string;
|
|
84
|
+
events: string[];
|
|
85
|
+
secret: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Response from GET /v1/keys — the calling agent's own key info. The relay
|
|
89
|
+
* scopes this to the authenticated agent: it returns ONE key (the caller's),
|
|
90
|
+
* not a list.
|
|
91
|
+
*/
|
|
92
|
+
export interface KeyInfo {
|
|
93
|
+
agent_id: string;
|
|
94
|
+
name: string | null;
|
|
95
|
+
key_prefix: string;
|
|
96
|
+
created_at: string;
|
|
97
|
+
last_used_at: string | null;
|
|
98
|
+
revoked_at: string | null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Response from GET /v1/taste, PUT /v1/taste — the calling agent's freeform
|
|
102
|
+
* "taste notes" markdown attachment (presentation preferences the agent has picked
|
|
103
|
+
* up from human feedback over time). `taste` and `updated_at` are null when
|
|
104
|
+
* the agent has never written notes; `bytes` is the utf8 byte length and 0
|
|
105
|
+
* when `taste` is null.
|
|
106
|
+
*/
|
|
107
|
+
export interface TasteInfo {
|
|
108
|
+
taste: string | null;
|
|
109
|
+
updated_at: string | null;
|
|
110
|
+
bytes: number;
|
|
111
|
+
}
|
|
112
|
+
/** A feedback `type` discriminant accepted by POST /v1/feedback. */
|
|
113
|
+
export type FeedbackType = "bug" | "feature" | "note";
|
|
114
|
+
/** Response from POST /v1/feedback — id, type, created_at only (no message echo). */
|
|
115
|
+
export interface FeedbackSubmission {
|
|
116
|
+
id: string;
|
|
117
|
+
type: FeedbackType;
|
|
118
|
+
created_at: string;
|
|
119
|
+
}
|
|
120
|
+
/** A row from GET /v1/feedback, the full record including message. */
|
|
121
|
+
export interface FeedbackRecord {
|
|
122
|
+
id: string;
|
|
123
|
+
type: FeedbackType;
|
|
124
|
+
message: string;
|
|
125
|
+
app_id: string | null;
|
|
126
|
+
created_at: string;
|
|
127
|
+
}
|
|
128
|
+
/** Response from GET /v1/feedback — page of the calling agent's own submissions. */
|
|
129
|
+
export interface FeedbackPage {
|
|
130
|
+
items: FeedbackRecord[];
|
|
131
|
+
next_before?: string;
|
|
132
|
+
}
|
|
133
|
+
/** A relay error envelope. */
|
|
134
|
+
export interface RelayError {
|
|
135
|
+
code: string;
|
|
136
|
+
message?: string;
|
|
137
|
+
details?: unknown;
|
|
138
|
+
/** Agent-friendly remediation hint. */
|
|
139
|
+
hint?: string;
|
|
140
|
+
/** Whether retrying the same request may succeed. */
|
|
141
|
+
retryable?: boolean;
|
|
142
|
+
/** Documentation URL for this error class (snake_case on the wire). */
|
|
143
|
+
docs_url?: string;
|
|
144
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Wire types for the Homespun relay HTTP + WebSocket API.
|
|
2
|
+
//
|
|
3
|
+
// These mirror the relay's public response shapes (see the relay's
|
|
4
|
+
// src/types.ts, src/http/serialize.ts and src/http/routes/*). They are
|
|
5
|
+
// re-declared here rather than imported from @homespunapps/relay so that @homespunapps/core
|
|
6
|
+
// stays pure and framework-free — no Prisma, no Hono, no server deps.
|
|
7
|
+
export {};
|