@lunora/client 1.0.0-alpha.21 → 1.0.0-alpha.23
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/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/index.d.mts +249 -4
- package/dist/index.d.ts +249 -4
- package/dist/index.mjs +10 -4
- package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
- package/dist/packem_shared/{LunoraClient-kXpHNyaE.mjs → LunoraClient-BBCQjjbl.mjs} +382 -243
- package/dist/packem_shared/{OfflineQueue-GGYJRmhF.mjs → OfflineQueue-B4HUF7rt.mjs} +1 -1
- package/dist/packem_shared/SubscriptionRegistry-CxS_Inha.mjs +31 -0
- package/dist/packem_shared/TabCoordinator-BwRR8H06.mjs +222 -0
- package/dist/packem_shared/createClientQuery-CQ51bWAE.mjs +71 -0
- package/dist/packem_shared/createLocalStore-BtqUmOQA.mjs +2 -0
- package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
- package/dist/packem_shared/{createServerClient-DF-3mLmb.mjs → createServerClient-CTTAmvMx.mjs} +1 -1
- package/dist/packem_shared/createSnapshotPrecondition-CxQ1T4ZP.mjs +18 -0
- package/dist/packem_shared/httpStream-BJU-aflc.mjs +159 -0
- package/dist/packem_shared/{local-store-BveBeFEo.mjs → local-store-DIq-UWfD.mjs} +1 -1
- package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.mts → lunora-client.d-JvtVpf8A.d.mts} +302 -18
- package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.ts → lunora-client.d-JvtVpf8A.d.ts} +302 -18
- package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
- package/dist/packem_shared/{preload.d-B-vyHnml.d.ts → preload.d-C4_d_l5v.d.ts} +1 -1
- package/dist/packem_shared/{preload.d-DrfuisCE.d.mts → preload.d-DKbjGN5O.d.mts} +1 -1
- package/dist/packem_shared/wire-key-Djie6aaR.mjs +266 -0
- package/dist/query/index.d.mts +2 -2
- package/dist/query/index.d.ts +2 -2
- package/dist/ssr/index.d.mts +3 -3
- package/dist/ssr/index.d.ts +3 -3
- package/dist/ssr/index.mjs +1 -1
- package/package.json +2 -2
- package/dist/packem_shared/SubscriptionRegistry-DjGKZsqq.mjs +0 -1
- package/dist/packem_shared/createLocalStore-jRoqmazl.mjs +0 -2
- package/dist/packem_shared/subscription-BjynOXCU.mjs +0 -68
|
@@ -1 +1 @@
|
|
|
1
|
-
export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-
|
|
1
|
+
export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { s as stableWireKey } from './wire-key-Djie6aaR.mjs';
|
|
2
|
+
|
|
3
|
+
class SubscriptionRegistry {
|
|
4
|
+
static key(functionPath, args, shardKey) {
|
|
5
|
+
return `${functionPath}::${stableWireKey(args)}::${shardKey ?? ""}`;
|
|
6
|
+
}
|
|
7
|
+
byKey = /* @__PURE__ */ new Map();
|
|
8
|
+
byId = /* @__PURE__ */ new Map();
|
|
9
|
+
get(key) {
|
|
10
|
+
return this.byKey.get(key);
|
|
11
|
+
}
|
|
12
|
+
getById(id) {
|
|
13
|
+
return this.byId.get(id);
|
|
14
|
+
}
|
|
15
|
+
add(state) {
|
|
16
|
+
this.byKey.set(SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey), state);
|
|
17
|
+
this.byId.set(state.id, state);
|
|
18
|
+
}
|
|
19
|
+
remove(state) {
|
|
20
|
+
const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
|
|
21
|
+
if (this.byKey.get(key) === state) {
|
|
22
|
+
this.byKey.delete(key);
|
|
23
|
+
}
|
|
24
|
+
this.byId.delete(state.id);
|
|
25
|
+
}
|
|
26
|
+
all() {
|
|
27
|
+
return [...this.byKey.values()];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { SubscriptionRegistry };
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
let nextTabId = 0;
|
|
2
|
+
const DEFAULT_CHANNEL = "lunora-bridge";
|
|
3
|
+
const DEFAULT_HEARTBEAT_MS = 1e3;
|
|
4
|
+
const DEFAULT_LEADER_TIMEOUT_MS = 3e3;
|
|
5
|
+
class TabCoordinator {
|
|
6
|
+
bc;
|
|
7
|
+
tabId;
|
|
8
|
+
heartbeatInterval;
|
|
9
|
+
leaderTimeout;
|
|
10
|
+
/** The tab id of the current known leader, or `undefined` if no leader. */
|
|
11
|
+
knownLeader = void 0;
|
|
12
|
+
/** `true` when this tab believes it is the leader. */
|
|
13
|
+
leader = false;
|
|
14
|
+
/** `true` once `start()` has been called. */
|
|
15
|
+
running = false;
|
|
16
|
+
/** Timestamp of the most recent leader heartbeat. */
|
|
17
|
+
lastHeartbeat = 0;
|
|
18
|
+
heartbeatTimer = void 0;
|
|
19
|
+
leaderCheckTimer = void 0;
|
|
20
|
+
/** Callbacks set via constructor options. */
|
|
21
|
+
onBecomeLeader;
|
|
22
|
+
onStopBeingLeader;
|
|
23
|
+
onSubscriptionData;
|
|
24
|
+
onSubscriptionError;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
nextTabId += 1;
|
|
27
|
+
this.tabId = `tab_${String(nextTabId)}_${crypto.randomUUID()}`;
|
|
28
|
+
this.heartbeatInterval = options.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS;
|
|
29
|
+
this.leaderTimeout = options.leaderTimeout ?? DEFAULT_LEADER_TIMEOUT_MS;
|
|
30
|
+
this.onBecomeLeader = options.onBecomeLeader;
|
|
31
|
+
this.onStopBeingLeader = options.onStopBeingLeader;
|
|
32
|
+
this.onSubscriptionData = options.onSubscriptionData;
|
|
33
|
+
this.onSubscriptionError = options.onSubscriptionError;
|
|
34
|
+
if (typeof BroadcastChannel === "undefined") {
|
|
35
|
+
this.bc = void 0;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.bc = new BroadcastChannel(options.channelName ?? DEFAULT_CHANNEL);
|
|
39
|
+
this.bc.addEventListener("message", (event) => {
|
|
40
|
+
this.handleMessage(event.data);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// -----------------------------------------------------------------------
|
|
44
|
+
// Public API
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Start the coordinator: attempt to claim leadership and begin the
|
|
48
|
+
* heartbeat/leader-check cycle. Safe to call multiple times.
|
|
49
|
+
*/
|
|
50
|
+
start() {
|
|
51
|
+
if (this.running) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.running = true;
|
|
55
|
+
if (this.bc === void 0) {
|
|
56
|
+
this.becomeLeader();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.broadcast({ type: "claim-leadership", tabId: this.tabId, ts: Date.now() });
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
if (!this.running) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (this.knownLeader === void 0) {
|
|
65
|
+
this.becomeLeader();
|
|
66
|
+
}
|
|
67
|
+
}, this.leaderTimeout);
|
|
68
|
+
this.leaderCheckTimer = setInterval(() => {
|
|
69
|
+
this.checkLeaderHealth();
|
|
70
|
+
}, this.leaderTimeout);
|
|
71
|
+
this.heartbeatTimer = setInterval(() => {
|
|
72
|
+
this.sendHeartbeat();
|
|
73
|
+
}, this.heartbeatInterval);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Stop the coordinator: yield leadership (if held), close the channel, and
|
|
77
|
+
* clear all timers. Safe to call multiple times.
|
|
78
|
+
*/
|
|
79
|
+
stop() {
|
|
80
|
+
this.running = false;
|
|
81
|
+
if (this.leader) {
|
|
82
|
+
this.broadcast({ type: "yield-leadership", tabId: this.tabId });
|
|
83
|
+
this.leader = false;
|
|
84
|
+
this.knownLeader = void 0;
|
|
85
|
+
this.onStopBeingLeader?.();
|
|
86
|
+
}
|
|
87
|
+
if (this.heartbeatTimer !== void 0) {
|
|
88
|
+
clearInterval(this.heartbeatTimer);
|
|
89
|
+
this.heartbeatTimer = void 0;
|
|
90
|
+
}
|
|
91
|
+
if (this.leaderCheckTimer !== void 0) {
|
|
92
|
+
clearInterval(this.leaderCheckTimer);
|
|
93
|
+
this.leaderCheckTimer = void 0;
|
|
94
|
+
}
|
|
95
|
+
this.bc?.close();
|
|
96
|
+
}
|
|
97
|
+
/** `true` when this tab is the current WebSocket leader. */
|
|
98
|
+
isLeader() {
|
|
99
|
+
return this.leader;
|
|
100
|
+
}
|
|
101
|
+
/** The tab id of the current leader, or `undefined` if unknown / no leader. */
|
|
102
|
+
get leaderTabId() {
|
|
103
|
+
return this.knownLeader;
|
|
104
|
+
}
|
|
105
|
+
/** The id of this tab. */
|
|
106
|
+
get id() {
|
|
107
|
+
return this.tabId;
|
|
108
|
+
}
|
|
109
|
+
/** `true` when the coordinator has been started and is not yet stopped. */
|
|
110
|
+
get isRunning() {
|
|
111
|
+
return this.running;
|
|
112
|
+
}
|
|
113
|
+
// -----------------------------------------------------------------------
|
|
114
|
+
// Broadcasting
|
|
115
|
+
// -----------------------------------------------------------------------
|
|
116
|
+
/**
|
|
117
|
+
* Broadcast subscription data to all follower tabs. Only the leader should
|
|
118
|
+
* call this.
|
|
119
|
+
*/
|
|
120
|
+
broadcastSubscriptionData(key, data) {
|
|
121
|
+
if (!this.leader) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.broadcast({ type: "subscription-data", tabId: this.tabId, key, data });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Broadcast a subscription error to all follower tabs. Only the leader
|
|
128
|
+
* should call this.
|
|
129
|
+
*/
|
|
130
|
+
broadcastSubscriptionError(key, error) {
|
|
131
|
+
if (!this.leader) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.broadcast({ type: "subscription-error", tabId: this.tabId, key, error });
|
|
135
|
+
}
|
|
136
|
+
// -----------------------------------------------------------------------
|
|
137
|
+
// Internal
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
broadcast(message) {
|
|
140
|
+
this.bc?.postMessage(message);
|
|
141
|
+
}
|
|
142
|
+
handleMessage(message) {
|
|
143
|
+
switch (message.type) {
|
|
144
|
+
case "claim-leadership": {
|
|
145
|
+
if (message.tabId === this.tabId) {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (this.leader) {
|
|
149
|
+
this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (this.knownLeader === void 0 && message.tabId < this.tabId) {
|
|
153
|
+
this.knownLeader = message.tabId;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "heartbeat": {
|
|
158
|
+
this.lastHeartbeat = message.ts;
|
|
159
|
+
this.knownLeader = message.tabId;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "subscription-data": {
|
|
163
|
+
if (this.leader || message.tabId === this.tabId) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
this.onSubscriptionData?.(message.key, message.data);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "subscription-error": {
|
|
170
|
+
if (this.leader || message.tabId === this.tabId) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
this.onSubscriptionError?.(message.key, message.error);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "yield-leadership": {
|
|
177
|
+
if (this.knownLeader === message.tabId) {
|
|
178
|
+
this.knownLeader = void 0;
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
becomeLeader() {
|
|
185
|
+
if (this.leader) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.leader = true;
|
|
189
|
+
this.knownLeader = this.tabId;
|
|
190
|
+
this.lastHeartbeat = Date.now();
|
|
191
|
+
this.onBecomeLeader?.();
|
|
192
|
+
this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
|
|
193
|
+
}
|
|
194
|
+
sendHeartbeat() {
|
|
195
|
+
if (!this.leader) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
|
|
199
|
+
}
|
|
200
|
+
checkLeaderHealth() {
|
|
201
|
+
if (!this.running) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (this.leader) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (this.knownLeader !== void 0) {
|
|
208
|
+
const elapsed = Date.now() - this.lastHeartbeat;
|
|
209
|
+
if (elapsed > this.leaderTimeout) {
|
|
210
|
+
this.knownLeader = void 0;
|
|
211
|
+
this.broadcast({ type: "claim-leadership", tabId: this.tabId, ts: Date.now() });
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
if (this.running && this.knownLeader === void 0) {
|
|
214
|
+
this.becomeLeader();
|
|
215
|
+
}
|
|
216
|
+
}, this.leaderTimeout);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export { TabCoordinator };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
class ClientQueryStore {
|
|
2
|
+
/** Current values, keyed by the ref's stable key. Absent = never set. */
|
|
3
|
+
values = /* @__PURE__ */ new Map();
|
|
4
|
+
/** Subscribers keyed by ref key — notified on every set. */
|
|
5
|
+
subscribers = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Return the current value for `ref`, or `ref.defaultValue` if none has
|
|
8
|
+
* been set explicitly. Returns `ref.defaultValue` when the slot has been
|
|
9
|
+
* set to `undefined` (which is distinct from "never set").
|
|
10
|
+
*/
|
|
11
|
+
get(ref) {
|
|
12
|
+
if (this.values.has(ref.key)) {
|
|
13
|
+
return this.values.get(ref.key);
|
|
14
|
+
}
|
|
15
|
+
return ref.defaultValue;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Set a new value for `ref` and notify every subscriber. Pass `undefined`
|
|
19
|
+
* to reset the slot to `ref.defaultValue`.
|
|
20
|
+
*/
|
|
21
|
+
set(ref, value) {
|
|
22
|
+
this.values.set(ref.key, value);
|
|
23
|
+
this.notify(ref.key);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Delete the stored value for `ref`, resetting to `ref.defaultValue` and
|
|
27
|
+
* notifying subscribers.
|
|
28
|
+
*/
|
|
29
|
+
reset(ref) {
|
|
30
|
+
this.values.delete(ref.key);
|
|
31
|
+
this.notify(ref.key);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to changes for `ref`. The callback is NOT invoked on
|
|
35
|
+
* registration — callers should read the current value via
|
|
36
|
+
* {@link get} first. Returns an unsubscribe function.
|
|
37
|
+
*/
|
|
38
|
+
subscribe(ref, callback) {
|
|
39
|
+
let subs = this.subscribers.get(ref.key);
|
|
40
|
+
if (!subs) {
|
|
41
|
+
subs = /* @__PURE__ */ new Set();
|
|
42
|
+
this.subscribers.set(ref.key, subs);
|
|
43
|
+
}
|
|
44
|
+
subs.add(callback);
|
|
45
|
+
return () => {
|
|
46
|
+
subs.delete(callback);
|
|
47
|
+
if (subs.size === 0) {
|
|
48
|
+
this.subscribers.delete(ref.key);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Notify every subscriber of a value change for the given key. */
|
|
53
|
+
notify(key) {
|
|
54
|
+
const subs = this.subscribers.get(key);
|
|
55
|
+
if (!subs) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const value = this.values.get(key);
|
|
59
|
+
for (const callback of subs) {
|
|
60
|
+
try {
|
|
61
|
+
callback(value);
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const createClientQuery = (key, defaultValue) => {
|
|
68
|
+
return { defaultValue, key };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export { ClientQueryStore, createClientQuery };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const sendToSw = (sw, message, expectResponse = false) => new Promise((resolve, reject) => {
|
|
2
|
+
if (!sw) {
|
|
3
|
+
reject(new Error("No active service worker"));
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
const id = message.correlationId ?? crypto.randomUUID();
|
|
7
|
+
const outgoingMessage = { ...message, correlationId: id };
|
|
8
|
+
if (expectResponse) {
|
|
9
|
+
let timer;
|
|
10
|
+
const handler = (event) => {
|
|
11
|
+
if (event.data.correlationId === id) {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
navigator.serviceWorker.removeEventListener("message", handler);
|
|
14
|
+
resolve(event.data.payload);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
timer = setTimeout(() => {
|
|
18
|
+
navigator.serviceWorker.removeEventListener("message", handler);
|
|
19
|
+
reject(new Error(`SW message ${id} timed out`));
|
|
20
|
+
}, 3e4);
|
|
21
|
+
navigator.serviceWorker.addEventListener("message", handler);
|
|
22
|
+
sw.postMessage(outgoingMessage);
|
|
23
|
+
} else {
|
|
24
|
+
sw.postMessage(outgoingMessage);
|
|
25
|
+
resolve(void 0);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const createReply = (original, payload) => {
|
|
29
|
+
return {
|
|
30
|
+
type: `${original.type}:reply`,
|
|
31
|
+
payload,
|
|
32
|
+
correlationId: original.correlationId
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export { createReply, sendToSw };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { s as stableWireKey } from './wire-key-Djie6aaR.mjs';
|
|
2
|
+
|
|
3
|
+
const createSnapshotPrecondition = (client, functionRef, args, shardKey) => {
|
|
4
|
+
const snapshot = client.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
|
|
5
|
+
const snapshotKey = snapshot === void 0 ? void 0 : stableWireKey(snapshot);
|
|
6
|
+
return () => {
|
|
7
|
+
const current = client.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
|
|
8
|
+
if (snapshotKey === void 0 && current === void 0) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (snapshotKey === void 0 || current === void 0) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return stableWireKey(current) === snapshotKey;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { createSnapshotPrecondition as default };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
|
|
3
|
+
|
|
4
|
+
const SSE_FIELD_SPACE_RE = /^ /u;
|
|
5
|
+
const parameterToString = (value) => typeof value === "object" && value !== null ? JSON.stringify(value) : String(value);
|
|
6
|
+
const parseSseFrame = (raw) => {
|
|
7
|
+
let event = "";
|
|
8
|
+
const dataLines = [];
|
|
9
|
+
for (const line of raw.split("\n")) {
|
|
10
|
+
if (line.startsWith("event:")) {
|
|
11
|
+
event = line.slice("event:".length).replace(SSE_FIELD_SPACE_RE, "");
|
|
12
|
+
} else if (line.startsWith("data:")) {
|
|
13
|
+
dataLines.push(line.slice("data:".length).replace(SSE_FIELD_SPACE_RE, ""));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return { data: dataLines.join("\n"), event };
|
|
17
|
+
};
|
|
18
|
+
const buildHttpStreamUrl = (route, args, baseUrl) => {
|
|
19
|
+
const parameters = args.params ?? {};
|
|
20
|
+
const path = route.path.split("/").map((segment) => {
|
|
21
|
+
if (!segment.startsWith(":")) {
|
|
22
|
+
return segment;
|
|
23
|
+
}
|
|
24
|
+
const name = segment.slice(1);
|
|
25
|
+
const value = parameters[name];
|
|
26
|
+
if (value === void 0) {
|
|
27
|
+
throw new LunoraError("HTTP_STREAM_MISSING_PARAM", `httpStream: missing path param ":${name}" for route ${route.path}`);
|
|
28
|
+
}
|
|
29
|
+
return encodeURIComponent(parameterToString(value));
|
|
30
|
+
}).join("/");
|
|
31
|
+
const search = new URLSearchParams();
|
|
32
|
+
for (const [key, value] of Object.entries(args.searchParams ?? {})) {
|
|
33
|
+
if (value !== void 0) {
|
|
34
|
+
search.set(key, parameterToString(value));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const query = search.toString();
|
|
38
|
+
const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
39
|
+
return `${trimmedBase}${path}${query === "" ? "" : `?${query}`}`;
|
|
40
|
+
};
|
|
41
|
+
const handleSseFrame = (frame, handle) => {
|
|
42
|
+
if (frame.event === "complete") {
|
|
43
|
+
handle.complete();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (frame.event === "error") {
|
|
47
|
+
let payload = {};
|
|
48
|
+
try {
|
|
49
|
+
payload = JSON.parse(frame.data);
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
const message = typeof payload.message === "string" ? payload.message : "stream error";
|
|
53
|
+
const code = typeof payload.code === "string" ? payload.code : "HTTP_STREAM_ERROR";
|
|
54
|
+
handle.fail(new LunoraError(code, message));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if ((frame.event === "" || frame.event === "message") && frame.data !== "") {
|
|
58
|
+
try {
|
|
59
|
+
handle.push(JSON.parse(frame.data));
|
|
60
|
+
} catch {
|
|
61
|
+
handle.fail(new LunoraError("HTTP_STREAM_BAD_CHUNK", "httpStream: malformed SSE chunk (invalid JSON)"));
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
};
|
|
67
|
+
const pumpSseBody = async (body, handle) => {
|
|
68
|
+
const reader = body.getReader();
|
|
69
|
+
const decoder = new TextDecoder();
|
|
70
|
+
let buffer = "";
|
|
71
|
+
const drainFrames = () => {
|
|
72
|
+
let separatorIndex = buffer.indexOf("\n\n");
|
|
73
|
+
while (separatorIndex !== -1) {
|
|
74
|
+
const frame = parseSseFrame(buffer.slice(0, separatorIndex));
|
|
75
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
76
|
+
if (handleSseFrame(frame, handle)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
separatorIndex = buffer.indexOf("\n\n");
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
};
|
|
83
|
+
for (; ; ) {
|
|
84
|
+
const { done, value } = await reader.read();
|
|
85
|
+
if (done) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
|
|
89
|
+
if (drainFrames()) {
|
|
90
|
+
await reader.cancel().catch(() => {
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
buffer += decoder.decode().replaceAll("\r\n", "\n");
|
|
96
|
+
if (!drainFrames()) {
|
|
97
|
+
handle.fail(new LunoraError("HTTP_STREAM_INTERRUPTED", "httpStream: stream ended without a complete frame"));
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const httpStream = (route, args, options = {}) => {
|
|
101
|
+
const fetchImpl = options.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : void 0);
|
|
102
|
+
if (!fetchImpl) {
|
|
103
|
+
throw new LunoraError("INTERNAL", "httpStream: no `fetch` implementation available");
|
|
104
|
+
}
|
|
105
|
+
const url = buildHttpStreamUrl(route, args ?? {}, options.baseUrl ?? "");
|
|
106
|
+
const ac = new AbortController();
|
|
107
|
+
if (options.signal) {
|
|
108
|
+
if (options.signal.aborted) {
|
|
109
|
+
ac.abort();
|
|
110
|
+
} else {
|
|
111
|
+
options.signal.addEventListener(
|
|
112
|
+
"abort",
|
|
113
|
+
() => {
|
|
114
|
+
ac.abort();
|
|
115
|
+
},
|
|
116
|
+
{ once: true }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const { handle, iterable } = createStream({
|
|
121
|
+
maxBuffer: options.maxBuffer,
|
|
122
|
+
onCancel: () => {
|
|
123
|
+
ac.abort();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
(async () => {
|
|
127
|
+
const response = await fetchImpl(url, {
|
|
128
|
+
headers: { accept: "text/event-stream", ...options.headers },
|
|
129
|
+
method: route.method,
|
|
130
|
+
signal: ac.signal
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
handle.fail(
|
|
134
|
+
new LunoraError("HTTP_STREAM_STATUS", `httpStream: request failed (status ${response.status.toString()})`, {
|
|
135
|
+
status: response.status
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!response.body) {
|
|
141
|
+
handle.fail(new LunoraError("HTTP_STREAM_NO_BODY", "httpStream: response has no body"));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await pumpSseBody(response.body, handle);
|
|
145
|
+
})().catch((error) => {
|
|
146
|
+
if (ac.signal.aborted) {
|
|
147
|
+
handle.complete();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (error instanceof Error && "code" in error) {
|
|
151
|
+
handle.fail(error);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
handle.fail(new LunoraError("HTTP_STREAM_TRANSPORT", error instanceof Error ? error.message : String(error), { cause: error }));
|
|
155
|
+
});
|
|
156
|
+
return iterable;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export { httpStream };
|