@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lalit Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @homespunapps/core
|
|
2
|
+
|
|
3
|
+
Typed client for the Homespun relay HTTP + WebSocket API. Framework-free: no argv,
|
|
4
|
+
no MCP, no server dependencies — just the relay protocol expressed as typed
|
|
5
|
+
operations.
|
|
6
|
+
|
|
7
|
+
## Runtime requirement: Node.js >= 20
|
|
8
|
+
|
|
9
|
+
`@homespunapps/core` targets the **Node.js** runtime (>= 20, as declared in
|
|
10
|
+
`package.json`'s `engines`). It is *framework*-free, not *runtime*-free.
|
|
11
|
+
|
|
12
|
+
The WebSocket transport (`openStream`) uses the [`ws`](https://www.npmjs.com/package/ws)
|
|
13
|
+
package rather than the global `WebSocket`. `ws` exposes a Node-style event API
|
|
14
|
+
(`socket.on("message", ...)`, custom upgrade headers such as `Authorization`)
|
|
15
|
+
that the browser `WebSocket` does not, and the relay protocol relies on it.
|
|
16
|
+
Because of this, `@homespunapps/core` is **not** intended to run in a browser or other
|
|
17
|
+
non-Node runtime as-is.
|
|
18
|
+
|
|
19
|
+
The HTTP app (`HomespunClient`, `registerAgent`) uses the standard `fetch` API
|
|
20
|
+
and is runtime-agnostic; only `openStream` carries the Node constraint.
|
|
21
|
+
|
|
22
|
+
If you need a browser client, treat that as separate future work — it would
|
|
23
|
+
need a `ws`-vs-global-`WebSocket` abstraction rather than the unconditional
|
|
24
|
+
`import { WebSocket } from "ws"` used today.
|
|
25
|
+
|
|
26
|
+
## Exports
|
|
27
|
+
|
|
28
|
+
- `HomespunClient` / `HomespunApiError` — typed HTTP operations against a relay.
|
|
29
|
+
- `openStream` — WebSocket stream (replay-on-connect, then live). **Node only.**
|
|
30
|
+
- `registerAgent` — agent registration helper.
|
|
31
|
+
- `feedbackTypeSchema`, `submitFeedbackSchema` — Zod schemas for the feedback API.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import type { AppFeedEntry } from "./client.js";
|
|
3
|
+
export interface OpenAppStreamOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The app's OWN WebSocket origin + path, e.g.
|
|
6
|
+
* `wss://grocery-x7k2m9.homespunapps.com/_hs/ws` — derived from the
|
|
7
|
+
* `url` a deploy/show response returns (swap https->wss, append `_hs/ws`).
|
|
8
|
+
*/
|
|
9
|
+
wsUrl: string;
|
|
10
|
+
/** Agent API key, carried via the `homespun.agentkey.<key>` subprotocol token. */
|
|
11
|
+
apiKey: string;
|
|
12
|
+
/** Resume cursor — replay only feed entries with seq > since. */
|
|
13
|
+
since?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface AppStreamHandlers {
|
|
16
|
+
/** Fired once on connect with the app's hello metadata. */
|
|
17
|
+
onHello?: (hello: {
|
|
18
|
+
seq: number;
|
|
19
|
+
app: {
|
|
20
|
+
slug: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description: string | null;
|
|
23
|
+
icon: string | null;
|
|
24
|
+
visibility: string;
|
|
25
|
+
collections: Array<{
|
|
26
|
+
name: string;
|
|
27
|
+
appendOnly: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
session: {
|
|
31
|
+
kind: "owner" | "member" | "anonymous";
|
|
32
|
+
humanId: string | null;
|
|
33
|
+
};
|
|
34
|
+
}) => void;
|
|
35
|
+
/** Fired for every feed entry — replayed (via batch) or live. */
|
|
36
|
+
onEntry?: (entry: AppFeedEntry) => void;
|
|
37
|
+
/** Fired once the initial catch-up (from `opts.since`) is fully drained. */
|
|
38
|
+
onCaughtUp?: () => void;
|
|
39
|
+
/** Fired on a `resync` frame — the caller should full-resync each collection. */
|
|
40
|
+
onResync?: () => void;
|
|
41
|
+
/** Fired on the terminal `_dormant` frame (the app went dormant). */
|
|
42
|
+
onDormant?: () => void;
|
|
43
|
+
/** Fired on a relay-side error frame. */
|
|
44
|
+
onRelayError?: (error: {
|
|
45
|
+
code?: string;
|
|
46
|
+
message?: string;
|
|
47
|
+
details?: unknown;
|
|
48
|
+
}) => void;
|
|
49
|
+
/** Fired when the socket closes (cleanly or otherwise). */
|
|
50
|
+
onClose?: (info: {
|
|
51
|
+
code: number;
|
|
52
|
+
reason: string;
|
|
53
|
+
}) => void;
|
|
54
|
+
/** Fired on a transport-level error (incl. a rejected upgrade). */
|
|
55
|
+
onError?: (err: Error) => void;
|
|
56
|
+
}
|
|
57
|
+
export interface AppStreamHandle {
|
|
58
|
+
close(): void;
|
|
59
|
+
readonly socket: WebSocket;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Open a WebSocket to an app's `/_hs/ws` endpoint as an agent. Drives the
|
|
63
|
+
* `sub`/`batch` catch-up loop internally (re-subscribing while
|
|
64
|
+
* `truncated:true`) so callers only handle individual entries.
|
|
65
|
+
*/
|
|
66
|
+
export declare function openAppStream(opts: OpenAppStreamOptions, handlers: AppStreamHandlers): AppStreamHandle;
|
|
67
|
+
/** Derive an app's `/_hs/ws` URL from its `url` field (https -> wss). */
|
|
68
|
+
export declare function appWsUrlFromAppUrl(appUrl: string): string;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// WebSocket client for the v2 app change-feed endpoint, `GET /_hs/ws` on
|
|
2
|
+
// the app's OWN usercontent origin (spec-serving §5, spec-cli §5) — NOT the
|
|
3
|
+
// main relay domain. An agent/CLI watcher authenticates via a second
|
|
4
|
+
// `Sec-WebSocket-Protocol` subprotocol token carrying its API key:
|
|
5
|
+
//
|
|
6
|
+
// Sec-WebSocket-Protocol: homespun.v1, homespun.agentkey.<agent-api-key>
|
|
7
|
+
//
|
|
8
|
+
// Frame protocol (relay's ws/app-handler.ts):
|
|
9
|
+
// server -> client {"type":"hello", seq, app:{...}, session:{...}, auth:{...}}
|
|
10
|
+
// client -> server {"type":"sub", "since":<seq>}
|
|
11
|
+
// server -> client {"type":"batch","entries":[...],"cursor":<seq>,"truncated":bool}
|
|
12
|
+
// server -> client {"type":"entry","entry":{...}} (live push)
|
|
13
|
+
// server -> client {"type":"resync"} (since below retention floor)
|
|
14
|
+
// server -> client {"type":"_dormant"} (terminal — app went dormant)
|
|
15
|
+
// server -> client {"type":"error","error":{...}}
|
|
16
|
+
//
|
|
17
|
+
// `openAppStream` sends the initial `sub` itself (from `opts.since`) and
|
|
18
|
+
// automatically re-subscribes with the batch's own cursor whenever a batch
|
|
19
|
+
// comes back `truncated:true`, so the caller only ever sees a clean stream of
|
|
20
|
+
// individual `AppFeedEntry` objects via `onEntry` — identical in shape to
|
|
21
|
+
// what `HomespunClient#getAppFeed`'s long-poll fallback returns, which is what
|
|
22
|
+
// lets `homespun apps watch` print the exact same JSON line regardless of
|
|
23
|
+
// transport (spec-cli §3.4/§5).
|
|
24
|
+
import { WebSocket } from "ws";
|
|
25
|
+
/**
|
|
26
|
+
* Open a WebSocket to an app's `/_hs/ws` endpoint as an agent. Drives the
|
|
27
|
+
* `sub`/`batch` catch-up loop internally (re-subscribing while
|
|
28
|
+
* `truncated:true`) so callers only handle individual entries.
|
|
29
|
+
*/
|
|
30
|
+
export function openAppStream(opts, handlers) {
|
|
31
|
+
const socket = new WebSocket(opts.wsUrl, [
|
|
32
|
+
"homespun.v1",
|
|
33
|
+
`homespun.agentkey.${opts.apiKey}`,
|
|
34
|
+
]);
|
|
35
|
+
let lastSeen = opts.since ?? 0;
|
|
36
|
+
const sendSub = (since) => {
|
|
37
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
38
|
+
return;
|
|
39
|
+
socket.send(JSON.stringify({ type: "sub", since }));
|
|
40
|
+
};
|
|
41
|
+
socket.on("open", () => {
|
|
42
|
+
sendSub(lastSeen);
|
|
43
|
+
});
|
|
44
|
+
socket.on("message", (raw) => {
|
|
45
|
+
let msg;
|
|
46
|
+
try {
|
|
47
|
+
msg = JSON.parse(raw.toString());
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
handlers.onError?.(new Error(`failed to parse app stream frame as JSON: ${e instanceof Error ? e.message : String(e)}`));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!msg || typeof msg !== "object") {
|
|
54
|
+
handlers.onError?.(new Error("unexpected non-object app stream frame"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const obj = msg;
|
|
58
|
+
switch (obj["type"]) {
|
|
59
|
+
case "hello":
|
|
60
|
+
handlers.onHello?.(obj);
|
|
61
|
+
return;
|
|
62
|
+
case "batch": {
|
|
63
|
+
const entries = obj["entries"] ?? [];
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (entry.seq <= lastSeen)
|
|
66
|
+
continue;
|
|
67
|
+
lastSeen = entry.seq;
|
|
68
|
+
handlers.onEntry?.(entry);
|
|
69
|
+
}
|
|
70
|
+
const cursor = obj["cursor"];
|
|
71
|
+
if (cursor !== undefined)
|
|
72
|
+
lastSeen = Math.max(lastSeen, cursor);
|
|
73
|
+
if (obj["truncated"] === true) {
|
|
74
|
+
// More history remains — immediately re-subscribe from the new
|
|
75
|
+
// cursor to drain the rest of the backlog.
|
|
76
|
+
sendSub(lastSeen);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
handlers.onCaughtUp?.();
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
case "entry": {
|
|
84
|
+
const entry = obj["entry"];
|
|
85
|
+
if (entry.seq <= lastSeen)
|
|
86
|
+
return;
|
|
87
|
+
lastSeen = entry.seq;
|
|
88
|
+
handlers.onEntry?.(entry);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
case "resync":
|
|
92
|
+
handlers.onResync?.();
|
|
93
|
+
return;
|
|
94
|
+
case "_dormant":
|
|
95
|
+
handlers.onDormant?.();
|
|
96
|
+
return;
|
|
97
|
+
case "error":
|
|
98
|
+
handlers.onRelayError?.(obj["error"]);
|
|
99
|
+
return;
|
|
100
|
+
default:
|
|
101
|
+
handlers.onError?.(new Error(`unrecognized app stream frame type '${String(obj["type"])}'`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
socket.on("close", (code, reason) => {
|
|
105
|
+
handlers.onClose?.({ code, reason: reason.toString() });
|
|
106
|
+
});
|
|
107
|
+
socket.on("error", (err) => {
|
|
108
|
+
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
close() {
|
|
112
|
+
try {
|
|
113
|
+
socket.close();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* already closing */
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
get socket() {
|
|
120
|
+
return socket;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Derive an app's `/_hs/ws` URL from its `url` field (https -> wss). */
|
|
125
|
+
export function appWsUrlFromAppUrl(appUrl) {
|
|
126
|
+
const u = new URL(appUrl);
|
|
127
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
128
|
+
u.pathname = u.pathname.replace(/\/$/, "") + "/_hs/ws";
|
|
129
|
+
u.search = "";
|
|
130
|
+
return u.toString();
|
|
131
|
+
}
|