@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/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 };