@streamsy/serve 0.0.0-stage → 0.4.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 +107 -2
- package/dist/action/contract.d.ts +100 -0
- package/dist/action/contract.js +28 -0
- package/dist/action/errors.d.ts +35 -0
- package/dist/action/errors.js +51 -0
- package/dist/action/outbox.d.ts +67 -0
- package/dist/action/outbox.js +109 -0
- package/dist/action/runtime.d.ts +80 -0
- package/dist/action/runtime.js +182 -0
- package/dist/action/sql.d.ts +8 -0
- package/dist/action/sql.js +229 -0
- package/dist/alchemy.d.ts +25 -0
- package/dist/alchemy.js +39 -0
- package/dist/bun.d.ts +13 -0
- package/dist/bun.js +14 -0
- package/dist/cloudflare.d.ts +35 -0
- package/dist/cloudflare.js +40 -0
- package/dist/contract-BUWJ-Sy2.d.ts +67 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +124 -0
- package/dist/deno.d.ts +27 -0
- package/dist/deno.js +32 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +337 -0
- package/dist/node.d.ts +20 -0
- package/dist/node.js +58 -0
- package/dist/placement-BOM3E08P.d.ts +60 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/router-DviIc1O7.js +130 -0
- package/dist/wakeup-BtnMEjLv.d.ts +56 -0
- package/dist/wakeup-CWKg8pts.js +125 -0
- package/package.json +132 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import { STATE_CONTRACT_HEADER, STATE_VERSION_HEADER, STREAM_CONTRACT_HEADER, STREAM_VERSION_HEADER, VALUE_CONTRACT_HEADER, canonicalJson, contractFingerprint, valueEtag } from "./contract.js";
|
|
3
|
+
import { Cause, Duration, Effect, Exit, Layer, Runtime, Schema } from "effect";
|
|
4
|
+
import { HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
|
|
5
|
+
import { Http, Offset } from "@streamsy/core";
|
|
6
|
+
import { StreamsReader } from "@streamsy/core/protocol";
|
|
7
|
+
import { ShutdownSignal, layerShutdownSignal } from "@streamsy/core/http";
|
|
8
|
+
//#region src/errors.ts
|
|
9
|
+
/** The public error body. `cases` gives one validating constructor per tag. */
|
|
10
|
+
const PublicErrorSchema = Schema.TaggedUnion({
|
|
11
|
+
InvalidParams: {
|
|
12
|
+
route: Schema.String,
|
|
13
|
+
parameter: Schema.String,
|
|
14
|
+
detail: Schema.String
|
|
15
|
+
},
|
|
16
|
+
ProtocolVersionUnsupported: {
|
|
17
|
+
route: Schema.String,
|
|
18
|
+
supported: Schema.Literal(1),
|
|
19
|
+
received: Schema.String,
|
|
20
|
+
recovery: Schema.Literal("replay-from-start")
|
|
21
|
+
},
|
|
22
|
+
ResumeRejected: {
|
|
23
|
+
route: Schema.String,
|
|
24
|
+
reason: Schema.Literals([
|
|
25
|
+
"invalid-offset",
|
|
26
|
+
"history-unavailable",
|
|
27
|
+
"contract-changed"
|
|
28
|
+
]),
|
|
29
|
+
recovery: Schema.Literal("replay-from-start")
|
|
30
|
+
},
|
|
31
|
+
ContractChanged: {
|
|
32
|
+
route: Schema.String,
|
|
33
|
+
recovery: Schema.Literal("refetch")
|
|
34
|
+
},
|
|
35
|
+
TransportUnavailable: {
|
|
36
|
+
route: Schema.String,
|
|
37
|
+
detail: Schema.String
|
|
38
|
+
},
|
|
39
|
+
WireEncodeFailed: {
|
|
40
|
+
route: Schema.String,
|
|
41
|
+
detail: Schema.String
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
var InvalidParams = class extends Schema.TaggedError()("InvalidParams", PublicErrorSchema.cases.InvalidParams.fields, { httpApiStatus: 400 }) {
|
|
45
|
+
responseHeaders = {};
|
|
46
|
+
[HttpServerRespondable.symbol]() {
|
|
47
|
+
return Effect.succeed(errorResponse(this, 400, this.responseHeaders));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var ProtocolVersionUnsupported = class extends Schema.TaggedError()("ProtocolVersionUnsupported", PublicErrorSchema.cases.ProtocolVersionUnsupported.fields, { httpApiStatus: 409 }) {
|
|
51
|
+
responseHeaders = {};
|
|
52
|
+
[HttpServerRespondable.symbol]() {
|
|
53
|
+
return Effect.succeed(errorResponse(this, 409, this.responseHeaders));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var ResumeRejected = class extends Schema.TaggedError()("ResumeRejected", PublicErrorSchema.cases.ResumeRejected.fields, { httpApiStatus: 409 }) {
|
|
57
|
+
responseHeaders = {};
|
|
58
|
+
[HttpServerRespondable.symbol]() {
|
|
59
|
+
return Effect.succeed(errorResponse(this, 409, this.responseHeaders));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var ContractChanged = class extends Schema.TaggedError()("ContractChanged", PublicErrorSchema.cases.ContractChanged.fields, { httpApiStatus: 409 }) {
|
|
63
|
+
responseHeaders = {};
|
|
64
|
+
[HttpServerRespondable.symbol]() {
|
|
65
|
+
return Effect.succeed(errorResponse(this, 409, this.responseHeaders));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var TransportUnavailable = class extends Schema.TaggedError()("TransportUnavailable", PublicErrorSchema.cases.TransportUnavailable.fields, { httpApiStatus: 503 }) {
|
|
69
|
+
responseHeaders = {};
|
|
70
|
+
[HttpServerRespondable.symbol]() {
|
|
71
|
+
return Effect.succeed(errorResponse(this, 503, this.responseHeaders));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var WireEncodeFailed = class extends Schema.TaggedError()("WireEncodeFailed", PublicErrorSchema.cases.WireEncodeFailed.fields, { httpApiStatus: 500 }) {
|
|
75
|
+
responseHeaders = {};
|
|
76
|
+
[HttpServerRespondable.symbol]() {
|
|
77
|
+
return Effect.succeed(errorResponse(this, 500, this.responseHeaders));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/** Response metadata stays out of the public JSON body. */
|
|
81
|
+
function withHeaders(error, headers) {
|
|
82
|
+
error.responseHeaders = headers;
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
function errorResponse(error, status, headers) {
|
|
86
|
+
return HttpServerResponse.jsonUnsafe(Schema.encodeSync(PublicErrorSchema)(error), {
|
|
87
|
+
status,
|
|
88
|
+
headers: {
|
|
89
|
+
...Http.securityHeaders,
|
|
90
|
+
...headers,
|
|
91
|
+
"cache-control": "no-store"
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/host.ts
|
|
97
|
+
const drain = (timeout = Duration.seconds(2)) => (serve) => {
|
|
98
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
99
|
+
const signal = yield* ShutdownSignal;
|
|
100
|
+
yield* Effect.addFinalizer(() => signal.drain(timeout));
|
|
101
|
+
})).pipe(Layer.provideMerge(serve), Layer.provide(layerShutdownSignal));
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* `HttpRouter.serve` with the Streamsy drain applied in the only correct place.
|
|
105
|
+
*
|
|
106
|
+
* Before the serve layer closes, the drain signals every live read: a parked
|
|
107
|
+
* long poll answers `204` with its offset and cursor, and an open SSE body ends
|
|
108
|
+
* after its last whole batch. It then waits until no live read is running, or
|
|
109
|
+
* `drainTimeout` passes. After that the serve layer interrupts any handler that
|
|
110
|
+
* is still running.
|
|
111
|
+
*
|
|
112
|
+
* The host provides storage, the listener and the Effect main:
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* const Server = Serve.layer(routes).pipe(
|
|
116
|
+
* Layer.provide(MemoryStorage.layer(streams)),
|
|
117
|
+
* Layer.provide(listener()),
|
|
118
|
+
* );
|
|
119
|
+
* BunRuntime.runMain(Layer.launch(Server), { teardown: Serve.teardown });
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
const layer = (routes, options) => HttpRouter.serve(routes, options).pipe(drain(options?.drainTimeout));
|
|
123
|
+
/**
|
|
124
|
+
* Exit policy for a long-running Streamsy host after `Serve.layer` drains.
|
|
125
|
+
* Pass it to the runtime's `runMain` as `{ teardown: Serve.teardown }`.
|
|
126
|
+
*
|
|
127
|
+
* An exit that holds only interrupts maps to 0: after the drain, an interrupt
|
|
128
|
+
* from `SIGINT` or `SIGTERM` is the normal way for the server to stop. Every
|
|
129
|
+
* other exit uses `Runtime.defaultTeardown`, so a startup failure stays
|
|
130
|
+
* non-zero. Any interrupt-only exit maps to 0, including one that no signal
|
|
131
|
+
* caused. In a `Layer.launch` main the fiber ends only on failure or a signal.
|
|
132
|
+
*/
|
|
133
|
+
const teardown = (exit, onExit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) ? onExit(0) : Runtime.defaultTeardown(exit, onExit);
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/serve.ts
|
|
136
|
+
var serve_exports = /* @__PURE__ */ __exportAll({
|
|
137
|
+
ContractChanged: () => ContractChanged,
|
|
138
|
+
InvalidParams: () => InvalidParams,
|
|
139
|
+
ProtocolVersionUnsupported: () => ProtocolVersionUnsupported,
|
|
140
|
+
ResumeRejected: () => ResumeRejected,
|
|
141
|
+
TransportUnavailable: () => TransportUnavailable,
|
|
142
|
+
WireEncodeFailed: () => WireEncodeFailed,
|
|
143
|
+
layer: () => layer,
|
|
144
|
+
mount: () => mount,
|
|
145
|
+
params: () => params,
|
|
146
|
+
route: () => route,
|
|
147
|
+
state: () => state,
|
|
148
|
+
stream: () => stream,
|
|
149
|
+
teardown: () => teardown,
|
|
150
|
+
value: () => value
|
|
151
|
+
});
|
|
152
|
+
/** Decode the matched route's parameters with the stream's codecs. */
|
|
153
|
+
const params = Effect.fn("Serve.params")(function* (declaration) {
|
|
154
|
+
const { route: matchedRoute } = yield* HttpRouter.RouteContext;
|
|
155
|
+
return yield* HttpRouter.schemaPathParams(declaration.paramSchema).pipe(Effect.mapError(() => new InvalidParams({
|
|
156
|
+
route: matchedRoute.path,
|
|
157
|
+
parameter: "params",
|
|
158
|
+
detail: "Invalid path parameters"
|
|
159
|
+
})));
|
|
160
|
+
});
|
|
161
|
+
/** Own every method at this path, including beneath protocol wildcard routes. */
|
|
162
|
+
function route(path, handler) {
|
|
163
|
+
return HttpRouter.add("*", path, Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => request.method === "GET" || request.method === "HEAD" ? handler : Effect.succeed(HttpServerResponse.empty({
|
|
164
|
+
status: 405,
|
|
165
|
+
headers: {
|
|
166
|
+
...Http.securityHeaders,
|
|
167
|
+
allow: "GET, HEAD"
|
|
168
|
+
}
|
|
169
|
+
}))));
|
|
170
|
+
}
|
|
171
|
+
/** Mount one declared stream; state addresses select the Durable State contract. */
|
|
172
|
+
function mount(declaration, path, options) {
|
|
173
|
+
return route(path, Effect.gen(function* () {
|
|
174
|
+
const p = yield* params(declaration);
|
|
175
|
+
const address = yield* Effect.try({
|
|
176
|
+
try: () => declaration.at(p),
|
|
177
|
+
catch: () => new InvalidParams({
|
|
178
|
+
route: path,
|
|
179
|
+
parameter: "params",
|
|
180
|
+
detail: "Parameters cannot form a canonical stream id"
|
|
181
|
+
})
|
|
182
|
+
});
|
|
183
|
+
return yield* read(address, options ?? {}, "collections" in address ? "state" : "stream", "collections" in address ? collectionDescriptor(address.collections) : Effect.succeed(null));
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
/** Durable Streams response for one resolved address. */
|
|
187
|
+
function stream(address, options = {}) {
|
|
188
|
+
return read(address, options, "stream", Effect.succeed(null));
|
|
189
|
+
}
|
|
190
|
+
/** Durable State change response with replay-from-start recovery. */
|
|
191
|
+
function state(address, options = {}) {
|
|
192
|
+
return read(address, options, "state", collectionDescriptor(address.collections));
|
|
193
|
+
}
|
|
194
|
+
function collectionDescriptor(value) {
|
|
195
|
+
return Effect.gen(function* () {
|
|
196
|
+
const { route: matchedRoute } = yield* HttpRouter.RouteContext;
|
|
197
|
+
const collections = yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Struct({ key: Schema.Union([Schema.String, Schema.Finite]) })))(value).pipe(Effect.mapError(() => new WireEncodeFailed({
|
|
198
|
+
route: matchedRoute.path,
|
|
199
|
+
detail: "Invalid collection metadata"
|
|
200
|
+
})));
|
|
201
|
+
return Object.fromEntries(Object.entries(collections).map(([type, collection]) => [type, collection.key]));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const read = Effect.fn("Serve.read")(function* (address, options, kind, descriptor) {
|
|
205
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
206
|
+
const { route: matchedRoute } = yield* HttpRouter.RouteContext;
|
|
207
|
+
const path = matchedRoute.path;
|
|
208
|
+
const versionHeader = kind === "state" ? STATE_VERSION_HEADER : STREAM_VERSION_HEADER;
|
|
209
|
+
const contractHeader = kind === "state" ? STATE_CONTRACT_HEADER : STREAM_CONTRACT_HEADER;
|
|
210
|
+
const source = "template" in address.stream && Schema.is(Schema.String)(address.stream.template) ? address.stream.template : "";
|
|
211
|
+
const collections = yield* descriptor;
|
|
212
|
+
const fingerprint = contractFingerprint({
|
|
213
|
+
kind,
|
|
214
|
+
route: path,
|
|
215
|
+
source,
|
|
216
|
+
id: address.id,
|
|
217
|
+
collections,
|
|
218
|
+
params: path.split("/").filter((segment) => segment.startsWith(":")).map((segment) => segment.slice(1)).toSorted(),
|
|
219
|
+
version: 1,
|
|
220
|
+
recovery: "replay-from-start",
|
|
221
|
+
contract: options.contract ?? null
|
|
222
|
+
});
|
|
223
|
+
const headers = {
|
|
224
|
+
[versionHeader]: "1",
|
|
225
|
+
[contractHeader]: fingerprint
|
|
226
|
+
};
|
|
227
|
+
const reject = (reason) => Effect.fail(withHeaders(new ResumeRejected({
|
|
228
|
+
route: path,
|
|
229
|
+
reason,
|
|
230
|
+
recovery: "replay-from-start"
|
|
231
|
+
}), headers));
|
|
232
|
+
const received = request.headers[versionHeader] ?? "1";
|
|
233
|
+
if (received !== "1") return yield* withHeaders(new ProtocolVersionUnsupported({
|
|
234
|
+
route: path,
|
|
235
|
+
supported: 1,
|
|
236
|
+
received,
|
|
237
|
+
recovery: "replay-from-start"
|
|
238
|
+
}), headers);
|
|
239
|
+
if (request.headers[contractHeader] !== void 0 && request.headers[contractHeader] !== fingerprint) return yield* reject("contract-changed");
|
|
240
|
+
if (kind === "state" && request.headers["x-streamsy-state-reset"] === "snapshot") return HttpServerResponse.text("Snapshot reset is unsupported; replay from -1", {
|
|
241
|
+
status: 400,
|
|
242
|
+
headers: {
|
|
243
|
+
...headers,
|
|
244
|
+
...Http.securityHeaders
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
const offset = new URL(request.url, "http://streamsy.internal").searchParams.get("offset");
|
|
248
|
+
const resume = offset !== null && offset !== "-1" && offset !== "now" && request.method !== "HEAD";
|
|
249
|
+
if (resume && !Schema.is(Offset)(offset)) return yield* reject("invalid-offset");
|
|
250
|
+
const service = yield* address.stream;
|
|
251
|
+
return yield* Http.read(address.id, options).pipe(Effect.provideService(StreamsReader, service.wire), Effect.map(HttpServerResponse.setHeaders(headers)), Effect.catchTags({
|
|
252
|
+
StreamNotFound: () => resume ? reject("history-unavailable") : Effect.succeed(HttpServerResponse.empty({
|
|
253
|
+
status: 404,
|
|
254
|
+
headers: {
|
|
255
|
+
...headers,
|
|
256
|
+
...Http.securityHeaders
|
|
257
|
+
}
|
|
258
|
+
})),
|
|
259
|
+
StreamGone: () => resume ? reject("history-unavailable") : Effect.succeed(HttpServerResponse.empty({
|
|
260
|
+
status: 410,
|
|
261
|
+
headers: {
|
|
262
|
+
...headers,
|
|
263
|
+
...Http.securityHeaders
|
|
264
|
+
}
|
|
265
|
+
})),
|
|
266
|
+
InvalidReadRequest: () => reject("invalid-offset"),
|
|
267
|
+
NotSupported: () => Effect.succeed(HttpServerResponse.empty({
|
|
268
|
+
status: 400,
|
|
269
|
+
headers: {
|
|
270
|
+
...headers,
|
|
271
|
+
...Http.securityHeaders
|
|
272
|
+
}
|
|
273
|
+
})),
|
|
274
|
+
StorageFault: () => Effect.fail(withHeaders(new TransportUnavailable({
|
|
275
|
+
route: path,
|
|
276
|
+
detail: "Storage unavailable"
|
|
277
|
+
}), headers)),
|
|
278
|
+
TransportFault: () => Effect.fail(withHeaders(new TransportUnavailable({
|
|
279
|
+
route: path,
|
|
280
|
+
detail: "Transport unavailable"
|
|
281
|
+
}), headers))
|
|
282
|
+
}));
|
|
283
|
+
});
|
|
284
|
+
/** Canonical JSON response for any computation, including mapped projection state. */
|
|
285
|
+
const value = Effect.fn("Serve.value")(function* (schema, compute, options = {}) {
|
|
286
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
287
|
+
const { route: matchedRoute } = yield* HttpRouter.RouteContext;
|
|
288
|
+
const path = matchedRoute.path;
|
|
289
|
+
const cache = options.cache ?? {
|
|
290
|
+
visibility: "private",
|
|
291
|
+
maxAgeSeconds: 0,
|
|
292
|
+
mustRevalidate: true
|
|
293
|
+
};
|
|
294
|
+
const cacheControl = `${cache.visibility}, max-age=${cache.maxAgeSeconds}${cache.mustRevalidate ? ", must-revalidate" : ""}`;
|
|
295
|
+
const fingerprint = contractFingerprint({
|
|
296
|
+
kind: "value",
|
|
297
|
+
route: path,
|
|
298
|
+
cache: cacheControl,
|
|
299
|
+
contract: options.contract ?? null
|
|
300
|
+
});
|
|
301
|
+
const headers = {
|
|
302
|
+
...Http.securityHeaders,
|
|
303
|
+
[VALUE_CONTRACT_HEADER]: fingerprint
|
|
304
|
+
};
|
|
305
|
+
if (request.headers["x-streamsy-value-contract"] !== void 0 && request.headers["x-streamsy-value-contract"] !== fingerprint) return yield* withHeaders(new ContractChanged({
|
|
306
|
+
route: path,
|
|
307
|
+
recovery: "refetch"
|
|
308
|
+
}), headers);
|
|
309
|
+
const computed = yield* compute;
|
|
310
|
+
const encoded = yield* Schema.encodeEffect(schema)(computed).pipe(Effect.mapError(() => withHeaders(new WireEncodeFailed({
|
|
311
|
+
route: path,
|
|
312
|
+
detail: "Value cannot be encoded"
|
|
313
|
+
}), headers)));
|
|
314
|
+
const canonical = yield* Effect.try({
|
|
315
|
+
try: () => canonicalJson(encoded),
|
|
316
|
+
catch: () => withHeaders(new WireEncodeFailed({
|
|
317
|
+
route: path,
|
|
318
|
+
detail: "Value is not finite JSON"
|
|
319
|
+
}), headers)
|
|
320
|
+
});
|
|
321
|
+
const etag = valueEtag(canonical);
|
|
322
|
+
const responseHeaders = {
|
|
323
|
+
...headers,
|
|
324
|
+
"cache-control": cacheControl,
|
|
325
|
+
etag
|
|
326
|
+
};
|
|
327
|
+
if (request.headers["if-none-match"]?.split(",").some((tag) => tag.trim() === "*" || tag.trim().replace(/^W\//, "") === etag)) return HttpServerResponse.empty({
|
|
328
|
+
status: 304,
|
|
329
|
+
headers: responseHeaders
|
|
330
|
+
});
|
|
331
|
+
return HttpServerResponse.text(request.method === "HEAD" ? "" : canonical, { headers: {
|
|
332
|
+
...responseHeaders,
|
|
333
|
+
"content-type": "application/json"
|
|
334
|
+
} });
|
|
335
|
+
});
|
|
336
|
+
//#endregion
|
|
337
|
+
export { PublicErrorSchema, serve_exports as Serve };
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Duration, Layer } from "effect";
|
|
2
|
+
import { HttpServer } from "effect/http";
|
|
3
|
+
import { ServerOptions } from "node:http";
|
|
4
|
+
//#region src/node.d.ts
|
|
5
|
+
export declare const DEFAULT_PORT = 3000;
|
|
6
|
+
export declare const DEFAULT_HOSTNAME = "127.0.0.1";
|
|
7
|
+
export interface ListenerOptions {
|
|
8
|
+
readonly port?: number;
|
|
9
|
+
readonly hostname?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Options for `node:http` `createServer`. The Node defaults keep a parked
|
|
12
|
+
* long poll and an open SSE body alive: `requestTimeout` bounds only the
|
|
13
|
+
* time to receive a request, not the time to answer it.
|
|
14
|
+
*/
|
|
15
|
+
readonly server?: ServerOptions;
|
|
16
|
+
readonly gracefulShutdownTimeout?: Duration.Input;
|
|
17
|
+
}
|
|
18
|
+
/** The Node listener for a Streamsy host. */
|
|
19
|
+
export declare const listener: (options?: ListenerOptions) => Layer.Layer<import("effect/http/Etag").Generator | import("effect/http/HttpPlatform").HttpPlatform | HttpServer.HttpServer | import("@effect/platform-node/NodeServices").NodeServices, import("effect/http/HttpServerError").ServeError, never>;
|
|
20
|
+
//#endregion
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { HttpServer, HttpServerResponse } from "effect/http";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
5
|
+
//#region src/node.ts
|
|
6
|
+
const DEFAULT_PORT = 3e3;
|
|
7
|
+
const DEFAULT_HOSTNAME = "127.0.0.1";
|
|
8
|
+
const listenOptions = (options) => ({
|
|
9
|
+
port: options.port ?? 3e3,
|
|
10
|
+
host: options.hostname ?? "127.0.0.1",
|
|
11
|
+
...options.gracefulShutdownTimeout === void 0 ? { disablePreemptiveShutdown: true } : { gracefulShutdownTimeout: options.gracefulShutdownTimeout }
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Streamsy's protocol answers some requests with a raw body that holds a web
|
|
15
|
+
* `Response`. The web hosts (Bun, Deno, workerd) send that `Response` as it
|
|
16
|
+
* is. The Node server passes a raw body to `ServerResponse.end`, which cannot
|
|
17
|
+
* write a `Response`, so the request never ends. This turns such a body into
|
|
18
|
+
* a stream body with the same status and headers before Node writes it.
|
|
19
|
+
*/
|
|
20
|
+
const writableOnNode = (response) => {
|
|
21
|
+
const body = response.body;
|
|
22
|
+
if (!("body" in body) || !(body.body instanceof Response)) return response;
|
|
23
|
+
return HttpServerResponse.fromWeb(body.body).pipe(HttpServerResponse.setStatus(response.status, response.statusText), HttpServerResponse.setHeaders(response.headers), HttpServerResponse.mergeCookies(response.cookies));
|
|
24
|
+
};
|
|
25
|
+
const nodeServer = Layer.effect(HttpServer.HttpServer, Effect.gen(function* () {
|
|
26
|
+
const server = yield* HttpServer.HttpServer;
|
|
27
|
+
return HttpServer.make({
|
|
28
|
+
address: server.address,
|
|
29
|
+
serve: (httpEffect, middleware) => server.serve(Effect.map(middleware === void 0 ? httpEffect : middleware(httpEffect), writableOnNode))
|
|
30
|
+
});
|
|
31
|
+
}));
|
|
32
|
+
/**
|
|
33
|
+
* `NodeHttpServer` binds the port when the listener Layer builds. It adds its
|
|
34
|
+
* `request` handler only when the routes serve, and it removes that handler
|
|
35
|
+
* before the server closes. Node drops a request that has no `request`
|
|
36
|
+
* listener and never answers it. Bun and Deno answer such a request with 404.
|
|
37
|
+
*
|
|
38
|
+
* This server always has one `request` listener of its own. When no other
|
|
39
|
+
* listener is attached, it answers 503 with `Retry-After` and closes the
|
|
40
|
+
* connection. A client that retries then reaches the routes after they serve.
|
|
41
|
+
*/
|
|
42
|
+
const createUnavailableAwareServer = (options) => {
|
|
43
|
+
const server = createServer(options);
|
|
44
|
+
server.on("request", (_request, response) => {
|
|
45
|
+
if (server.listenerCount("request") > 1) return;
|
|
46
|
+
response.writeHead(503, {
|
|
47
|
+
"content-type": "text/plain; charset=utf-8",
|
|
48
|
+
"retry-after": "1",
|
|
49
|
+
connection: "close"
|
|
50
|
+
});
|
|
51
|
+
response.end("service unavailable");
|
|
52
|
+
});
|
|
53
|
+
return server;
|
|
54
|
+
};
|
|
55
|
+
/** The Node listener for a Streamsy host. */
|
|
56
|
+
const listener = (options = {}) => nodeServer.pipe(Layer.provideMerge(NodeHttpServer.layer(() => createUnavailableAwareServer(options.server ?? {}), listenOptions(options))));
|
|
57
|
+
//#endregion
|
|
58
|
+
export { DEFAULT_HOSTNAME, DEFAULT_PORT, listener };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { r as Wakeups } from "./wakeup-BtnMEjLv.js";
|
|
2
|
+
import { Context, Effect, Layer, Option, Scope } from "effect";
|
|
3
|
+
import { HttpRouter, HttpServerRespondable, HttpServerResponse } from "effect/http";
|
|
4
|
+
import { Storage, StorageFault } from "@streamsy/core";
|
|
5
|
+
//#region src/cloudflare/object-runtime.d.ts
|
|
6
|
+
type ObjectServices<R = never> = Storage | Wakeups | R;
|
|
7
|
+
type ObjectApp<R = never> = Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ObjectServices<R>> | HttpRouter.Request.From<"Error", HttpServerRespondable.Respondable>>;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/cloudflare/placement.d.ts
|
|
10
|
+
interface Placement {
|
|
11
|
+
readonly name: (streamPath: string) => string;
|
|
12
|
+
}
|
|
13
|
+
/** The parse side of a stream, which is all a placement rule reads. */
|
|
14
|
+
interface Parser<Params> {
|
|
15
|
+
parse(id: string): Option.Option<Params>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* One rule in a stream placement: a stream and the object name its
|
|
19
|
+
* addresses belong to.
|
|
20
|
+
*
|
|
21
|
+
* The owner takes the same parameters its own stream decodes, so the object name
|
|
22
|
+
* and the storage routing agree by construction. `owner` uses method syntax on
|
|
23
|
+
* purpose: it gives the parameter its contextually typed shape at the call site
|
|
24
|
+
* and keeps the rule assignable to the erased table.
|
|
25
|
+
*/
|
|
26
|
+
interface OwnerRule<Params> {
|
|
27
|
+
readonly stream: Parser<Params>;
|
|
28
|
+
/** Names the object that owns every stream matching `stream`. */
|
|
29
|
+
owner(params: Params): string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A rule after its parameter type is erased. `byRoute` reads each rule only
|
|
33
|
+
* through that rule's own `parse`, so the erased parameter is never observed.
|
|
34
|
+
*/
|
|
35
|
+
interface ErasedOwnerRule {
|
|
36
|
+
readonly stream: Parser<unknown>;
|
|
37
|
+
owner(params: never): string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Declare one placement rule with its stream's parameters in scope.
|
|
41
|
+
*
|
|
42
|
+
* The helper exists so the owner parameter is typed and checked. A rule passed
|
|
43
|
+
* to `Placement.byRoute` without it would see the erased parameter set instead.
|
|
44
|
+
*/
|
|
45
|
+
declare const rule: <Params>(spec: OwnerRule<Params>) => ErasedOwnerRule;
|
|
46
|
+
declare const Placement: {
|
|
47
|
+
readonly byStream: () => Placement;
|
|
48
|
+
readonly byKey: (key: (streamPath: string) => string) => Placement;
|
|
49
|
+
/**
|
|
50
|
+
* The first matching rule names the object.
|
|
51
|
+
*
|
|
52
|
+
* A path that no rule matches yields `""`, which the router turns into
|
|
53
|
+
* `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
|
|
54
|
+
* stream cannot decode a segment does not own that path, so the table falls
|
|
55
|
+
* through to the next rule.
|
|
56
|
+
*/
|
|
57
|
+
readonly byRoute: (rules: ReadonlyArray<ErasedOwnerRule>) => Placement;
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
export { ObjectServices as i, rule as n, ObjectApp as r, Placement as t };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll as t };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { i as makeWakeups, r as Wakeups, t as RequestTouch } from "./wakeup-CWKg8pts.js";
|
|
2
|
+
import { Context, Effect, Exit, Layer, Option, Scope } from "effect";
|
|
3
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
4
|
+
import { Http } from "@streamsy/core";
|
|
5
|
+
import { securityHeaders, streamPath } from "@streamsy/core/http";
|
|
6
|
+
//#region src/cloudflare/host-program.ts
|
|
7
|
+
/**
|
|
8
|
+
* Reconciliation belongs to the mutation's finalizer. A request interruption
|
|
9
|
+
* can be pending when the mutation's own uninterruptible region ends, so a
|
|
10
|
+
* second sequential uninterruptible effect is not sufficient. A failed
|
|
11
|
+
* reconciliation is logged and does not replace the mutation's result.
|
|
12
|
+
*/
|
|
13
|
+
const withMutationReconciliation = (effect, reconcile) => effect.pipe(Effect.ensuring(reconcile.pipe(Effect.uninterruptible, Effect.catchCause((cause) => Effect.logError("Wakeup reconciliation failed", cause)))));
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/cloudflare/object-runtime.ts
|
|
16
|
+
/** Both object hosts install this middleware, including around user routes. */
|
|
17
|
+
function objectApp(app, context) {
|
|
18
|
+
const middleware = HttpRouter.middleware((handler) => Effect.gen(function* () {
|
|
19
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
20
|
+
const marker = { touched: false };
|
|
21
|
+
const wakeups = Context.get(context, Wakeups);
|
|
22
|
+
return yield* withMutationReconciliation(handler, Effect.suspend(() => [
|
|
23
|
+
"PUT",
|
|
24
|
+
"POST",
|
|
25
|
+
"DELETE"
|
|
26
|
+
].includes(request.method) || marker.touched ? wakeups.reconcile : Effect.void)).pipe(Effect.catchDefect(() => Effect.succeed(HttpServerResponse.text("Internal server error", {
|
|
27
|
+
status: 500,
|
|
28
|
+
headers: Http.securityHeaders
|
|
29
|
+
}))), Effect.provideService(RequestTouch, marker));
|
|
30
|
+
}).pipe(Effect.provide(context)), { global: true });
|
|
31
|
+
return Layer.merge(app, middleware);
|
|
32
|
+
}
|
|
33
|
+
const unavailable = () => HttpServerResponse.text("Storage unavailable", {
|
|
34
|
+
status: 503,
|
|
35
|
+
headers: {
|
|
36
|
+
...Http.securityHeaders,
|
|
37
|
+
"retry-after": "1"
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
/** One lazy successful acquisition per object owner. Failed attempts close before retry. */
|
|
41
|
+
function acquireObject(layer, compile, wakeups) {
|
|
42
|
+
return Effect.gen(function* () {
|
|
43
|
+
const owner = yield* Scope.Scope;
|
|
44
|
+
return yield* Effect.gen(function* () {
|
|
45
|
+
const attempt = yield* Scope.fork(owner);
|
|
46
|
+
return yield* Effect.gen(function* () {
|
|
47
|
+
const context = yield* Layer.buildWithScope(layer, attempt);
|
|
48
|
+
const scheduler = yield* makeWakeups(context, wakeups);
|
|
49
|
+
return yield* compile(scheduler.context, scheduler.alarm).pipe(Effect.provideService(Scope.Scope, attempt));
|
|
50
|
+
}).pipe(Effect.onExit((exit) => Exit.isFailure(exit) ? Scope.close(attempt, exit) : Effect.void));
|
|
51
|
+
}).pipe(Effect.cachedWithTTL((exit) => Exit.isSuccess(exit) ? "Infinity" : 0));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Request markers are all supplied by this exact object context. */
|
|
55
|
+
function providedApp(app, context) {
|
|
56
|
+
return objectApp(app, context).pipe(HttpRouter.provideRequest(Layer.succeedContext(context)));
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/cloudflare/placement.ts
|
|
60
|
+
/**
|
|
61
|
+
* Declare one placement rule with its stream's parameters in scope.
|
|
62
|
+
*
|
|
63
|
+
* The helper exists so the owner parameter is typed and checked. A rule passed
|
|
64
|
+
* to `Placement.byRoute` without it would see the erased parameter set instead.
|
|
65
|
+
*/
|
|
66
|
+
const rule = (spec) => spec;
|
|
67
|
+
const Placement = {
|
|
68
|
+
byStream: () => ({ name: (streamPath) => streamPath }),
|
|
69
|
+
byKey: (key) => ({ name: key }),
|
|
70
|
+
/**
|
|
71
|
+
* The first matching rule names the object.
|
|
72
|
+
*
|
|
73
|
+
* A path that no rule matches yields `""`, which the router turns into
|
|
74
|
+
* `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
|
|
75
|
+
* stream cannot decode a segment does not own that path, so the table falls
|
|
76
|
+
* through to the next rule.
|
|
77
|
+
*/
|
|
78
|
+
byRoute: (rules) => ({ name: (streamPath) => {
|
|
79
|
+
for (const entry of rules) {
|
|
80
|
+
const params = entry.stream.parse(streamPath);
|
|
81
|
+
if (Option.isNone(params)) continue;
|
|
82
|
+
return entry.owner(params.value);
|
|
83
|
+
}
|
|
84
|
+
return "";
|
|
85
|
+
} })
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/cloudflare/router.ts
|
|
89
|
+
const badRequest = (message) => new Response(message, {
|
|
90
|
+
status: 400,
|
|
91
|
+
headers: securityHeaders
|
|
92
|
+
});
|
|
93
|
+
const internalError = () => new Response("Internal server error", {
|
|
94
|
+
status: 500,
|
|
95
|
+
headers: securityHeaders
|
|
96
|
+
});
|
|
97
|
+
const invalidPlacement = () => badRequest("Invalid placement key");
|
|
98
|
+
const resolvePlacement = (placement, streamPath) => {
|
|
99
|
+
try {
|
|
100
|
+
const candidate = placement.name(streamPath);
|
|
101
|
+
const name = String(candidate);
|
|
102
|
+
return candidate === name && name.length > 0 ? {
|
|
103
|
+
ok: true,
|
|
104
|
+
name
|
|
105
|
+
} : {
|
|
106
|
+
ok: false,
|
|
107
|
+
response: invalidPlacement()
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
response: internalError()
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const router = (options) => {
|
|
117
|
+
const path = streamPath(options.prefix);
|
|
118
|
+
const placement = options.placement ?? Placement.byStream();
|
|
119
|
+
return { fetch(request, env) {
|
|
120
|
+
const url = new URL(request.url);
|
|
121
|
+
const streamPath = path.strip(url.pathname);
|
|
122
|
+
if (!streamPath || streamPath === url.pathname) return badRequest(`Stream path required: ${path.requiredPathPattern()}`);
|
|
123
|
+
const child = resolvePlacement(placement, streamPath);
|
|
124
|
+
if (!child.ok) return child.response;
|
|
125
|
+
const namespace = options.namespace(env);
|
|
126
|
+
return namespace.get(namespace.idFromName(child.name)).fetch(request);
|
|
127
|
+
} };
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
export { acquireObject as a, rule as i, router as n, providedApp as o, Placement as r, unavailable as s, resolvePlacement as t };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Context, Duration, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { Storage, StorageFault } from "@streamsy/core";
|
|
3
|
+
import { DurableObjectStorage } from "@cloudflare/workers-types";
|
|
4
|
+
//#region src/cloudflare/alarm.d.ts
|
|
5
|
+
declare const AlarmSlot_base: Context.ServiceClass<AlarmSlot, "@streamsy/serve/cloudflare/AlarmSlot", {
|
|
6
|
+
readonly current: Effect.Effect<Option.Option<number>>;
|
|
7
|
+
readonly arm: (at: number) => Effect.Effect<void>;
|
|
8
|
+
readonly clear: Effect.Effect<void>;
|
|
9
|
+
}>;
|
|
10
|
+
declare class AlarmSlot extends AlarmSlot_base {}
|
|
11
|
+
declare const alarmSlotLayer: (storage: DurableObjectStorage) => Layer.Layer<AlarmSlot>;
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/cloudflare/wakeup.d.ts
|
|
14
|
+
/**
|
|
15
|
+
* A bounded run that stopped at its bound returns `{ more: true }`. The
|
|
16
|
+
* scheduler then re-arms at once and does not count the pass as a failure.
|
|
17
|
+
*/
|
|
18
|
+
interface WakeupProgress {
|
|
19
|
+
readonly more: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** One source of deadline-driven work, derived from committed state. */
|
|
22
|
+
interface Wakeup<R = never> {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly next: Effect.Effect<Option.Option<number>, StorageFault, R>;
|
|
25
|
+
/**
|
|
26
|
+
* Bounded, idempotent work. Delivery is at least once. The permit for this
|
|
27
|
+
* name is not reentrant, so a run must not call its own wakeup's `run`.
|
|
28
|
+
*/
|
|
29
|
+
readonly run: Effect.Effect<void | WakeupProgress, unknown, R>;
|
|
30
|
+
readonly retry?: {
|
|
31
|
+
readonly initial?: Duration.Input;
|
|
32
|
+
readonly max?: Duration.Input;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
declare const Wakeup: {
|
|
36
|
+
make: <R>(wakeup: Wakeup<R>) => Wakeup<R>;
|
|
37
|
+
};
|
|
38
|
+
declare const Wakeups_base: Context.ServiceClass<Wakeups, "@streamsy/serve/cloudflare/Wakeups", {
|
|
39
|
+
/**
|
|
40
|
+
* Marks the current request so the host reconciles when the handler
|
|
41
|
+
* returns. Call it before the handler returns. A streamed body that
|
|
42
|
+
* queues work later must call `reconcile` itself.
|
|
43
|
+
*/
|
|
44
|
+
readonly touch: Effect.Effect<void>;
|
|
45
|
+
readonly reconcile: Effect.Effect<void>;
|
|
46
|
+
readonly status: Effect.Effect<ReadonlyArray<{
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly next: Option.Option<number>;
|
|
49
|
+
readonly failures: number;
|
|
50
|
+
/** The backoff floor. The wakeup does not run before this instant. */
|
|
51
|
+
readonly retryAt: Option.Option<number>;
|
|
52
|
+
}>>;
|
|
53
|
+
}>;
|
|
54
|
+
declare class Wakeups extends Wakeups_base {}
|
|
55
|
+
//#endregion
|
|
56
|
+
export { alarmSlotLayer as a, AlarmSlot as i, WakeupProgress as n, Wakeups as r, Wakeup as t };
|