@plurnk/plurnk-schemes-http 1.16.5 → 1.18.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/dist/Ws.js CHANGED
@@ -22,21 +22,10 @@ class Ws {
22
22
  channels: { [MESSAGES]: "text/plain" },
23
23
  defaultChannel: MESSAGES,
24
24
  category: "data",
25
- entryOwner: "worker",
26
- inherit: "none",
27
25
  writableBy: ["model", "client"],
28
26
  volatile: true,
29
27
  modelVisible: true,
30
28
  glyph: "🔌",
31
- example: [
32
- "### READ0 (wss://api.example.com/feed)",
33
- "",
34
- "### EDIT0 (wss://api.example.com/feed)",
35
- '{"type":"subscribe","channel":"updates"}',
36
- "",
37
- "### SEND0 (wss://api.example.com/feed)",
38
- '{"type":"message","text":"Hello"}',
39
- ].join("\n"),
40
29
  documentation,
41
30
  traits: ["web"],
42
31
  };
@@ -66,12 +55,13 @@ class Ws {
66
55
  return address;
67
56
  const { url } = address;
68
57
  const pathname = request.pathname;
69
- const key = _a.#key(ctx.workerId, address);
58
+ const key = _a.#key(ctx.workspaceId, address);
70
59
  const existing = this.#sockets.get(key);
71
60
  if (existing !== undefined) {
72
- return { status: 200, connectionState: existing.state };
61
+ return existing.opened ? { status: 200, connectionState: existing.state } : existing.acquisition;
73
62
  }
74
63
  const done = Promise.withResolvers();
64
+ const shared = Promise.withResolvers();
75
65
  const owner = {
76
66
  socket: null,
77
67
  state: "claimed",
@@ -80,241 +70,246 @@ class Ws {
80
70
  shutdownRequested: false,
81
71
  async shutdown() { },
82
72
  done: done.promise,
73
+ acquisition: shared.promise,
83
74
  };
84
75
  this.#sockets.set(key, owner);
85
- let retained = false;
86
- let settled = false;
87
- try {
88
- // {§ws-lifecycle} Seed the declared channel before subscription open.
89
- const written = await ctx.entries.write(pathname, _a.#seedEntry());
90
- if (Results.isErrorStatus(written.status))
91
- return _a.#passthrough(written);
92
- if (owner.shutdownRequested)
93
- return _a.#cancelled(url);
94
- let requestCancel = () => { owner.shutdownRequested = true; };
95
- const handle = { cancel: () => requestCancel() };
96
- const subscription = await ctx.subscriptions.open(pathname, handle);
97
- if (owner.shutdownRequested) {
98
- const cancelled = _a.#cancelled(url);
99
- await subscription.close(cancelled, "WebSocket execution was cancelled.");
100
- return cancelled;
101
- }
102
- let socket;
76
+ const acquire = async () => {
77
+ let retained = false;
78
+ let settled = false;
103
79
  try {
104
- socket = this.#connect(url);
105
- }
106
- catch (err) {
107
- console.error("WebSocket connection failed", { url, err });
108
- const detail = `The WebSocket connection to ${url} failed.`;
109
- const result = _a.#bad(502, "connect-failed", detail, {
110
- target: url,
111
- stage: "connection",
112
- retryable: true,
113
- });
114
- await subscription.close(result, detail);
115
- return result;
116
- }
117
- owner.socket = socket;
118
- owner.state = "connecting";
119
- const acquisition = Promise.withResolvers();
120
- let messages = 0;
121
- let settlement = null;
122
- let frameFailure = null;
123
- const pending = new Set();
124
- const settle = (terminal, summary, transportClose, initial = terminal) => {
125
- if (settled)
126
- return settlement ?? Promise.resolve();
127
- settled = true;
128
- owner.state = "settling";
129
- subscription.removeEventListener("abort", onAbort);
130
- settlement = (async () => {
131
- const errors = [];
132
- try {
133
- await Promise.allSettled([...pending]);
134
- const failedFrame = frameFailure;
135
- const effectiveTerminal = !Results.isErrorStatus(terminal.status) && failedFrame !== null
136
- ? failedFrame.result
137
- : terminal;
138
- const effectiveSummary = !Results.isErrorStatus(terminal.status) && failedFrame !== null
139
- ? failedFrame.summary
140
- : typeof summary === "function" ? summary() : summary;
141
- if (transportClose !== undefined) {
80
+ // {§ws-lifecycle} Seed the declared channel before subscription open.
81
+ const written = await ctx.entries.write(pathname, _a.#seedEntry());
82
+ if (Results.isErrorStatus(written.status))
83
+ return _a.#passthrough(written);
84
+ if (owner.shutdownRequested)
85
+ return _a.#cancelled(url);
86
+ let requestCancel = () => { owner.shutdownRequested = true; };
87
+ const handle = { cancel: () => requestCancel() };
88
+ const subscription = await ctx.subscriptions.open(pathname, handle);
89
+ if (owner.shutdownRequested) {
90
+ const cancelled = _a.#cancelled(url);
91
+ await subscription.close(cancelled, "WebSocket execution was cancelled.");
92
+ return cancelled;
93
+ }
94
+ let socket;
95
+ try {
96
+ socket = this.#connect(url);
97
+ }
98
+ catch (err) {
99
+ console.error("WebSocket connection failed", { url, err });
100
+ const detail = `The WebSocket connection to ${url} failed.`;
101
+ const result = _a.#bad(502, "connect-failed", detail, {
102
+ target: url,
103
+ stage: "connection",
104
+ retryable: true,
105
+ });
106
+ await subscription.close(result, detail);
107
+ return result;
108
+ }
109
+ owner.socket = socket;
110
+ owner.state = "connecting";
111
+ const acquisition = Promise.withResolvers();
112
+ let messages = 0;
113
+ let settlement = null;
114
+ let frameFailure = null;
115
+ const pending = new Set();
116
+ const settle = (terminal, summary, transportClose, initial = terminal) => {
117
+ if (settled)
118
+ return settlement ?? Promise.resolve();
119
+ settled = true;
120
+ owner.state = "settling";
121
+ subscription.removeEventListener("abort", onAbort);
122
+ settlement = (async () => {
123
+ const errors = [];
124
+ try {
125
+ await Promise.allSettled([...pending]);
126
+ const failedFrame = frameFailure;
127
+ const effectiveTerminal = !Results.isErrorStatus(terminal.status) && failedFrame !== null
128
+ ? failedFrame.result
129
+ : terminal;
130
+ const effectiveSummary = !Results.isErrorStatus(terminal.status) && failedFrame !== null
131
+ ? failedFrame.summary
132
+ : typeof summary === "function" ? summary() : summary;
133
+ if (transportClose !== undefined) {
134
+ try {
135
+ socket.close(transportClose.code, transportClose.reason);
136
+ }
137
+ catch (error) {
138
+ errors.push(error);
139
+ }
140
+ }
142
141
  try {
143
- socket.close(transportClose.code, transportClose.reason);
142
+ await subscription.close(effectiveTerminal, effectiveSummary);
143
+ if (!owner.opened)
144
+ acquisition.resolve(initial);
144
145
  }
145
146
  catch (error) {
147
+ if (!owner.opened)
148
+ acquisition.reject(error);
146
149
  errors.push(error);
147
150
  }
148
151
  }
149
- try {
150
- await subscription.close(effectiveTerminal, effectiveSummary);
151
- if (!owner.opened)
152
- acquisition.resolve(initial);
152
+ finally {
153
+ if (this.#sockets.get(key) === owner)
154
+ this.#sockets.delete(key);
155
+ done.resolve();
153
156
  }
154
- catch (error) {
155
- if (!owner.opened)
156
- acquisition.reject(error);
157
- errors.push(error);
157
+ if (errors.length === 1)
158
+ throw errors[0];
159
+ if (errors.length > 1)
160
+ throw new AggregateError(errors, "WebSocket terminal cleanup failed");
161
+ })();
162
+ return settlement;
163
+ };
164
+ const reportCleanupFailure = (error) => {
165
+ console.error("WebSocket terminal cleanup failed", { url, error });
166
+ };
167
+ const track = (task) => {
168
+ pending.add(task);
169
+ void task.then(() => pending.delete(task), () => pending.delete(task));
170
+ return task;
171
+ };
172
+ const cancelled = () => _a.#cancelled(url);
173
+ requestCancel = () => {
174
+ owner.shutdownRequested = true;
175
+ const result = cancelled();
176
+ void settle(result, "WebSocket execution was cancelled.", {
177
+ code: SOCKET_CLOSE_NORMAL,
178
+ reason: "cancelled",
179
+ }).catch(reportCleanupFailure);
180
+ };
181
+ owner.shutdown = () => {
182
+ const result = cancelled();
183
+ return settle(result, () => `ws closed (${SOCKET_CLOSE_NORMAL}); ${messages} messages`, {
184
+ code: SOCKET_CLOSE_NORMAL,
185
+ reason: "shutdown",
186
+ });
187
+ };
188
+ const onAbort = () => requestCancel();
189
+ subscription.addEventListener("abort", onAbort, { once: true });
190
+ const setChannelState = ctx.channels.setState.bind(ctx.channels);
191
+ const streamEvent = ctx.notify.streamEvent.bind(ctx.notify);
192
+ let nativeOpened = false;
193
+ let activation = Promise.resolve();
194
+ let persistenceTail = Promise.resolve();
195
+ socket.addEventListener("open", () => {
196
+ if (owner.state !== "connecting")
197
+ return;
198
+ nativeOpened = true;
199
+ activation = track((async () => {
200
+ try {
201
+ const activated = await setChannelState(pathname, MESSAGES, "active");
202
+ if (Results.isErrorStatus(activated.status)) {
203
+ const result = _a.#passthrough(activated);
204
+ void settle(result, result.problem?.detail ?? "WebSocket channel activation failed.", { code: SOCKET_CLOSE_INTERNAL_ERROR, reason: "activation failed" }).catch(reportCleanupFailure);
205
+ return;
206
+ }
207
+ if (owner.state !== "connecting")
208
+ return;
209
+ streamEvent(pathname, MESSAGES, "active", 0);
210
+ owner.opened = true;
211
+ owner.state = "open";
212
+ acquisition.resolve({ shape: "passthrough", status: 102 });
158
213
  }
159
- }
160
- finally {
161
- if (this.#sockets.get(key) === owner)
162
- this.#sockets.delete(key);
163
- done.resolve();
164
- }
165
- if (errors.length === 1)
166
- throw errors[0];
167
- if (errors.length > 1)
168
- throw new AggregateError(errors, "WebSocket terminal cleanup failed");
169
- })();
170
- return settlement;
171
- };
172
- const reportCleanupFailure = (error) => {
173
- console.error("WebSocket terminal cleanup failed", { url, error });
174
- };
175
- const track = (task) => {
176
- pending.add(task);
177
- void task.then(() => pending.delete(task), () => pending.delete(task));
178
- return task;
179
- };
180
- const cancelled = () => _a.#cancelled(url);
181
- requestCancel = () => {
182
- owner.shutdownRequested = true;
183
- const result = cancelled();
184
- void settle(result, "WebSocket execution was cancelled.", {
185
- code: SOCKET_CLOSE_NORMAL,
186
- reason: "cancelled",
187
- }).catch(reportCleanupFailure);
188
- };
189
- owner.shutdown = () => {
190
- const result = cancelled();
191
- return settle(result, () => `ws closed (${SOCKET_CLOSE_NORMAL}); ${messages} messages`, {
192
- code: SOCKET_CLOSE_NORMAL,
193
- reason: "shutdown",
194
- });
195
- };
196
- const onAbort = () => requestCancel();
197
- subscription.addEventListener("abort", onAbort, { once: true });
198
- const setChannelState = ctx.channels.setState.bind(ctx.channels);
199
- const streamEvent = ctx.notify.streamEvent.bind(ctx.notify);
200
- let nativeOpened = false;
201
- let activation = Promise.resolve();
202
- let persistenceTail = Promise.resolve();
203
- socket.addEventListener("open", () => {
204
- if (owner.state !== "connecting")
205
- return;
206
- nativeOpened = true;
207
- activation = track((async () => {
208
- try {
209
- const activated = await setChannelState(pathname, MESSAGES, "active");
210
- if (Results.isErrorStatus(activated.status)) {
211
- const result = _a.#passthrough(activated);
214
+ catch (err) {
215
+ console.error("WebSocket channel activation failed", { url, err });
216
+ const result = _a.#bad(500, "channel-activation-failed", "The WebSocket message channel could not enter its active state.", {
217
+ target: url,
218
+ stage: "persistence",
219
+ retryable: false,
220
+ });
212
221
  void settle(result, result.problem?.detail ?? "WebSocket channel activation failed.", { code: SOCKET_CLOSE_INTERNAL_ERROR, reason: "activation failed" }).catch(reportCleanupFailure);
213
- return;
214
222
  }
215
- if (owner.state !== "connecting")
216
- return;
217
- streamEvent(pathname, MESSAGES, "active", 0);
218
- owner.opened = true;
219
- owner.state = "open";
220
- acquisition.resolve({ shape: "passthrough", status: 102 });
221
- }
222
- catch (err) {
223
- console.error("WebSocket channel activation failed", { url, err });
224
- const result = _a.#bad(500, "channel-activation-failed", "The WebSocket message channel could not enter its active state.", {
225
- target: url,
226
- stage: "persistence",
227
- retryable: false,
228
- });
229
- void settle(result, result.problem?.detail ?? "WebSocket channel activation failed.", { code: SOCKET_CLOSE_INTERNAL_ERROR, reason: "activation failed" }).catch(reportCleanupFailure);
230
- }
231
- })());
232
- }, { once: true });
233
- // {§ws-lifecycle} One owner chain makes transport order independent
234
- // of the subscription implementation's asynchronous scheduler.
235
- socket.addEventListener("message", (event) => {
236
- if (!nativeOpened || settled || frameFailure !== null)
237
- return;
238
- const data = event.data;
239
- const persistence = persistenceTail.then(async () => {
240
- await activation;
241
- if (!owner.opened || frameFailure !== null)
223
+ })());
224
+ }, { once: true });
225
+ // {§ws-lifecycle} One owner chain makes transport order independent
226
+ // of the subscription implementation's asynchronous scheduler.
227
+ socket.addEventListener("message", (event) => {
228
+ if (!nativeOpened || settled || frameFailure !== null)
242
229
  return;
243
- if (typeof data !== "string") {
244
- const detail = "The received WebSocket frame is binary; the messages channel accepts text only.";
245
- const result = _a.#bad(415, "binary-frame-unsupported", detail, {
246
- target: url,
247
- stage: "materialization",
248
- retryable: false,
249
- });
250
- frameFailure = { result, summary: detail };
251
- void settle(result, detail, { code: SOCKET_CLOSE_UNSUPPORTED_DATA, reason: "binary unsupported" }).catch(reportCleanupFailure);
252
- return;
253
- }
254
- try {
255
- await subscription.notifyChunk(MESSAGES, data, "text/plain");
256
- messages += 1;
257
- }
258
- catch (err) {
259
- if (frameFailure !== null)
230
+ const data = event.data;
231
+ const persistence = persistenceTail.then(async () => {
232
+ await activation;
233
+ if (!owner.opened || frameFailure !== null)
260
234
  return;
261
- console.error("WebSocket message persistence failed", { url, err });
262
- const result = _a.#bad(500, "message-persistence-failed", "The received WebSocket message could not be persisted.", {
263
- target: url,
264
- stage: "persistence",
265
- retryable: false,
266
- });
267
- const failureSummary = result.problem?.detail ?? "WebSocket message persistence failed.";
268
- frameFailure = { result, summary: failureSummary };
269
- void settle(result, failureSummary, { code: SOCKET_CLOSE_INTERNAL_ERROR, reason: "persistence failed" }).catch(reportCleanupFailure);
270
- }
271
- });
272
- persistenceTail = persistence;
273
- void track(persistence);
274
- });
275
- socket.addEventListener("error", (event) => {
276
- console.error("WebSocket transport failed", { url, message: event.message });
277
- const detail = `The WebSocket connection to ${url} failed.`;
278
- const result = _a.#bad(502, "connection-failed", detail, {
279
- target: url,
280
- stage: "connection",
281
- retryable: true,
235
+ if (typeof data !== "string") {
236
+ const detail = "The received WebSocket frame is binary; the messages channel accepts text only.";
237
+ const result = _a.#bad(415, "binary-frame-unsupported", detail, {
238
+ target: url,
239
+ stage: "materialization",
240
+ retryable: false,
241
+ });
242
+ frameFailure = { result, summary: detail };
243
+ void settle(result, detail, { code: SOCKET_CLOSE_UNSUPPORTED_DATA, reason: "binary unsupported" }).catch(reportCleanupFailure);
244
+ return;
245
+ }
246
+ try {
247
+ await subscription.notifyChunk(MESSAGES, data, "text/plain");
248
+ messages += 1;
249
+ }
250
+ catch (err) {
251
+ if (frameFailure !== null)
252
+ return;
253
+ console.error("WebSocket message persistence failed", { url, err });
254
+ const result = _a.#bad(500, "message-persistence-failed", "The received WebSocket message could not be persisted.", {
255
+ target: url,
256
+ stage: "persistence",
257
+ retryable: false,
258
+ });
259
+ const failureSummary = result.problem?.detail ?? "WebSocket message persistence failed.";
260
+ frameFailure = { result, summary: failureSummary };
261
+ void settle(result, failureSummary, { code: SOCKET_CLOSE_INTERNAL_ERROR, reason: "persistence failed" }).catch(reportCleanupFailure);
262
+ }
263
+ });
264
+ persistenceTail = persistence;
265
+ void track(persistence);
282
266
  });
283
- void settle(result, detail, {
284
- code: SOCKET_CLOSE_INTERNAL_ERROR,
285
- reason: "transport failed",
286
- }).catch(reportCleanupFailure);
287
- });
288
- socket.addEventListener("close", (event) => {
289
- if (!owner.opened && !owner.killRequested && !subscription.aborted) {
290
- const detail = `The WebSocket connection to ${url} closed before it opened.`;
267
+ socket.addEventListener("error", (event) => {
268
+ console.error("WebSocket transport failed", { url, message: event.message });
269
+ const detail = `The WebSocket connection to ${url} failed.`;
291
270
  const result = _a.#bad(502, "connection-failed", detail, {
292
271
  target: url,
293
272
  stage: "connection",
294
273
  retryable: true,
295
274
  });
296
- void settle(result, detail).catch(reportCleanupFailure);
297
- return;
275
+ void settle(result, detail, {
276
+ code: SOCKET_CLOSE_INTERNAL_ERROR,
277
+ reason: "transport failed",
278
+ }).catch(reportCleanupFailure);
279
+ });
280
+ socket.addEventListener("close", (event) => {
281
+ if (!owner.opened && !owner.killRequested && !subscription.aborted) {
282
+ const detail = `The WebSocket connection to ${url} closed before it opened.`;
283
+ const result = _a.#bad(502, "connection-failed", detail, {
284
+ target: url,
285
+ stage: "connection",
286
+ retryable: true,
287
+ });
288
+ void settle(result, detail).catch(reportCleanupFailure);
289
+ return;
290
+ }
291
+ const summary = () => `ws closed (${event.code ?? 0}); ${messages} messages`;
292
+ const initial = subscription.aborted ? cancelled() : { shape: "passthrough", status: 102 };
293
+ const terminal = subscription.aborted ? initial : { shape: "passthrough", status: 200 };
294
+ void settle(terminal, summary, undefined, initial).catch(reportCleanupFailure);
295
+ });
296
+ if (owner.shutdownRequested || subscription.aborted)
297
+ requestCancel();
298
+ const result = await acquisition.promise;
299
+ if (owner.opened && result.status === 102)
300
+ retained = true;
301
+ return result;
302
+ }
303
+ finally {
304
+ if (!retained && !settled) {
305
+ if (this.#sockets.get(key) === owner)
306
+ this.#sockets.delete(key);
307
+ done.resolve();
298
308
  }
299
- const summary = () => `ws closed (${event.code ?? 0}); ${messages} messages`;
300
- const initial = subscription.aborted ? cancelled() : { shape: "passthrough", status: 102 };
301
- const terminal = subscription.aborted ? initial : { shape: "passthrough", status: 200 };
302
- void settle(terminal, summary, undefined, initial).catch(reportCleanupFailure);
303
- });
304
- if (owner.shutdownRequested || subscription.aborted)
305
- requestCancel();
306
- const result = await acquisition.promise;
307
- if (owner.opened && result.status === 102)
308
- retained = true;
309
- return result;
310
- }
311
- finally {
312
- if (!retained && !settled) {
313
- if (this.#sockets.get(key) === owner)
314
- this.#sockets.delete(key);
315
- done.resolve();
316
309
  }
317
- }
310
+ };
311
+ void acquire().then(shared.resolve, shared.reject);
312
+ return shared.promise;
318
313
  }
319
314
  async close() {
320
315
  const owners = [...this.#sockets.values()];
@@ -370,7 +365,7 @@ class Ws {
370
365
  }
371
366
  return this.#write(statement.target, statement.body ?? "", ctx, "EDIT");
372
367
  }
373
- // A disposition label never reaches the scheme ({§send-label}); a KILL closes the owner.
368
+ // {§turn-disposition} — SEND is messaging; a KILL closes the owner.
374
369
  async send(statement, ctx) {
375
370
  if (statement.metadata !== null)
376
371
  return _a.#metadataUnsupported();
@@ -381,20 +376,13 @@ class Ws {
381
376
  retryable: false,
382
377
  });
383
378
  }
384
- const status = statement.status;
385
- if (status === null)
386
- return this.#write(statement.target, statement.body?.raw ?? "", ctx, "SEND");
387
- return _a.#bad(501, "send-status-unsupported", `The WebSocket scheme does not interpret SEND status ${status}.`, {
388
- requestedStatus: status,
389
- stage: "dispatch",
390
- retryable: false,
391
- });
379
+ return this.#write(statement.target, statement.body?.raw ?? "", ctx, "SEND");
392
380
  }
393
381
  async #write(target, body, ctx, operation) {
394
382
  const address = _a.#address(target);
395
383
  if (!(address instanceof NetworkAddress))
396
384
  return address;
397
- const owner = this.#sockets.get(_a.#key(ctx.workerId, address));
385
+ const owner = this.#sockets.get(_a.#key(ctx.workspaceId, address));
398
386
  if (owner === undefined) {
399
387
  return _a.#bad(409, "no-open-socket", `No WebSocket connection is open for ${address.url}.`, {
400
388
  target: address.url,
@@ -448,7 +436,7 @@ class Ws {
448
436
  const address = _a.#address(statement.target);
449
437
  if (!(address instanceof NetworkAddress))
450
438
  return address;
451
- const owner = this.#sockets.get(_a.#key(ctx.workerId, address));
439
+ const owner = this.#sockets.get(_a.#key(ctx.workspaceId, address));
452
440
  if (owner === undefined) {
453
441
  return _a.#bad(404, "no-open-socket", `No WebSocket connection is open for ${address.url}.`, {
454
442
  target: address.url,
@@ -473,7 +461,7 @@ class Ws {
473
461
  return { shape: "passthrough", status: 200 };
474
462
  }
475
463
  catch (err) {
476
- if (this.#sockets.get(_a.#key(ctx.workerId, address)) === owner) {
464
+ if (this.#sockets.get(_a.#key(ctx.workspaceId, address)) === owner) {
477
465
  owner.killRequested = false;
478
466
  owner.state = priorState;
479
467
  }
@@ -485,10 +473,9 @@ class Ws {
485
473
  });
486
474
  }
487
475
  }
488
- // The durable entry is Worker-owned ({§manifest-entry-owner}); the live handle
489
- // shares that principal, and a Worker id already determines its workspace.
490
- static #key(workerId, address) {
491
- return `${workerId}:${address.scheme}:${address.pathname}`;
476
+ // {§runtime-resource-binding}: all workspace callers address the same live connection.
477
+ static #key(workspaceId, address) {
478
+ return `${workspaceId}:${address.url}`;
492
479
  }
493
480
  static #address(target) {
494
481
  let address;