@streamsy/serve 0.5.0 → 0.7.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Effect HttpRouter routes for streams, Durable State and JSON values, plus Bun,
4
4
  Node, Deno and Cloudflare host glue and durable action delivery. Requires Effect
5
- `4.0.1`.
5
+ `4.0.5`.
6
6
 
7
7
  ```ts
8
8
  import { Layer, Schema } from "effect";
@@ -85,8 +85,8 @@ The Durable Object host does not get a stop signal and uses neither.
85
85
  | `/bun` | `listener`, its options and defaults |
86
86
  | `/node` | `listener`, its options and defaults |
87
87
  | `/deno` | `listener`, its options and defaults; `nodeSqliteCompat` |
88
- | `/cloudflare` | `StreamsyObject`, placement router/rules, alarm service |
89
- | `/alchemy` | `objectHandlers`, placement router/rules, alarm service |
88
+ | `/cloudflare` | `StreamsyObject`, placement router/rules, alarm service, `ObjectLink`, `ObjectStreams` |
89
+ | `/alchemy` | `objectHandlers`, placement router/rules, alarm service, `ObjectLink`, `objectFetch` |
90
90
  | `/action` | Unchanged action declarations |
91
91
  | `/action/errors`, `/action/outbox`, `/action/runtime`, `/action/sql` | Action delivery and backing APIs; `createSqlOutboxBacking` runs on SQLite, Postgres and MySQL |
92
92
 
@@ -106,3 +106,38 @@ Guides: [Serve routes](https://streamsy.dev/docs/http/serve),
106
106
  [Cloudflare](https://streamsy.dev/docs/hosting/cloudflare),
107
107
  [Host contract](https://streamsy.dev/docs/hosting/host-contract),
108
108
  [Alchemy](https://streamsy.dev/docs/hosting/cloudflare#alchemy).
109
+
110
+ ## Object links and projection wakeups
111
+
112
+ `ObjectLink.make({ group, prefix, placement, access })` from `/cloudflare` or
113
+ `/alchemy` declares an internal protocol mount and its client together. Add
114
+ `link.routes` to the owner app and the client to the caller's `links`.
115
+ The prefix must start with `/__`. Both public placement routers return 404
116
+ for paths whose first segment starts with `__`, without calling an object.
117
+ The owner still needs the explicit internal route.
118
+
119
+ Both hosts use one `ObjectLink` and one `LinkClient`. A native Cloudflare
120
+ object takes `links: (env) => [link.client(env.OWNERS)]`. Alchemy
121
+ `objectHandlers` takes `links: [link.client(objectFetch(owners))]`. A linked
122
+ declaration must not also be provided by local storage; the host rejects that
123
+ clash during acquisition.
124
+
125
+ `ObjectStreams.layer(group, { objects, placement, prefix })` builds the same
126
+ remote stream services for an existing protocol mount. `objects` is a native
127
+ namespace or an `ObjectFetch`: `(name, request) => Effect<Response,
128
+ HttpClientError>`. The client uses `HttpClient.make`, retaining caller context,
129
+ tracing and interruption. Alchemy's `objectFetch` adapts its stub Effects.
130
+
131
+ Access is mount policy for every stream. `Streamsy.wake` is a State preset
132
+ with the normal writer service. Use `read` for public projection outputs,
133
+ `full` when an internal producer creates its output, or `append` when the
134
+ owner creates it first. `Serve.mount` accepts GET and HEAD.
135
+
136
+ Object hosts acquire `ProjectionWakeup.attach(projection)` from
137
+ `@streamsy/projection` in their scope through `wakeups: [...]`. Local
138
+ `Storage.committed` events populate its in-memory pending set, and each commit
139
+ writes a durable pending mark in the same transaction. Alarms drain bounded
140
+ passes. After an eviction, acquisition reads the marks and arms the alarm.
141
+ Retry options take an Effect `Schedule`; finite schedules stop on exhaustion.
142
+ `Wakeups.status` reports each wakeup's deferred bindings.
143
+ See the [projection host reference](https://streamsy.dev/docs/hosting/cloudflare#projections).
@@ -1,7 +1,7 @@
1
1
  import { ActionSinkRelation, CheckedActionSink } from "./contract.js";
2
2
  import { ActionSinkDeliveryFailure, OutboxUnavailable } from "./errors.js";
3
3
  import { OutboxDraft, OutboxStore } from "./outbox.js";
4
- import { t as Wakeup } from "../wakeup--kIoqiIE.js";
4
+ import { t as Wakeup } from "../wakeup-BJGZnq4x.js";
5
5
  import { Effect } from "effect";
6
6
  /** One attempt at one delivery, as the handler sees it. */
7
7
  export interface ActionSinkDelivery<Payload> {
@@ -1,7 +1,7 @@
1
1
  import { backoffAfter } from "./contract.js";
2
2
  import { ActionSinkDeliveryFailure } from "./errors.js";
3
3
  import { OutboxStore } from "./outbox.js";
4
- import { n as Wakeup } from "../wakeup-CcJE8k3i.js";
4
+ import { n as Wakeup } from "../wakeup-Cb5DAcpF.js";
5
5
  import { Array, Cause, Clock, Effect, Exit, Option, Semaphore } from "effect";
6
6
  import { StorageFault } from "@streamsy/core";
7
7
  // src/action/runtime.ts
package/dist/alchemy.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as alarmSlotLayer, i as AlarmSlot, n as WakeupProgress, r as Wakeups, t as Wakeup } from "./wakeup--kIoqiIE.js";
2
- import { i as ObjectServices, n as rule, r as ObjectApp, t as Placement } from "./placement-Cj3sd02S.js";
1
+ import { a as AlarmSlot, i as Wakeups, n as WakeupProgress, o as alarmSlotLayer, r as WakeupSource, t as Wakeup } from "./wakeup-BJGZnq4x.js";
2
+ import { a as ObjectLinkOptions, c as ObjectStreamsOptions, d as ObjectApp, i as ObjectLink, l as Placement, n as LinkClient, o as ObjectFetch, r as LinkRoutes, s as ObjectStreams, t as LinkAccess, u as rule } from "./object-link-B3cvdzGy.js";
3
3
  import { Effect, Layer, Scope } from "effect";
4
4
  import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/http";
5
5
  import { Storage, StorageFault } from "@streamsy/core";
@@ -10,13 +10,20 @@ export declare const router: (options: {
10
10
  readonly prefix?: `/${string}`;
11
11
  readonly placement?: Placement;
12
12
  }) => Effect.Effect<HttpServerResponse.HttpServerResponse, HttpServerError.HttpServerError, HttpServerRequest.HttpServerRequest>;
13
+ /**
14
+ * An `ObjectFetch` over an Alchemy Durable Object binding, for
15
+ * `ObjectStreams.layer` and `ObjectLink.client`.
16
+ */
17
+ export declare const objectFetch: (objects: Pick<DurableObject, "getByName">) => ObjectFetch;
13
18
  export type ObjectHandlers = {
14
19
  readonly fetch: Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest.HttpServerRequest>;
15
20
  readonly alarm: () => Effect.Effect<void>;
16
21
  };
17
- export declare function objectHandlers<R = never>(options: {
18
- readonly app: ObjectApp<R>;
19
- readonly layer: Layer.Layer<Storage | AlarmSlot | R, StorageFault>;
20
- readonly wakeups?: ReadonlyArray<Wakeup<ObjectServices<R>>>;
22
+ export declare function objectHandlers<R = never, L = never>(options: {
23
+ readonly app: ObjectApp<R | L>;
24
+ readonly layer: Layer.Layer<Storage | AlarmSlot | R, StorageFault, L>;
25
+ readonly wakeups?: ReadonlyArray<WakeupSource<Storage | R | L>>;
26
+ /** Streams that live on other objects, through `link.client(objectFetch(binding))`. */
27
+ readonly links?: ReadonlyArray<LinkClient<L>>;
21
28
  }): Effect.Effect<ObjectHandlers, never, Scope.Scope>;
22
- export { Placement, Wakeup, type WakeupProgress, Wakeups, alarmSlotLayer, rule };
29
+ export { type LinkAccess, type LinkClient, type LinkRoutes, type ObjectFetch, ObjectLink, type ObjectLinkOptions, ObjectStreams, type ObjectStreamsOptions, Placement, Wakeup, type WakeupProgress, type WakeupSource, Wakeups, alarmSlotLayer, rule };
package/dist/alchemy.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-CcJE8k3i.js";
2
- import { a as acquireObject, i as rule, o as providedApp, r as Placement, s as unavailable, t as resolvePlacement } from "./router-C2p1T25K.js";
1
+ import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-Cb5DAcpF.js";
2
+ import { a as resolvePlacement, c as rule, d as unavailable, f as withLinks, i as isReserved, l as acquireObject, n as ObjectStreams, r as objectTransportError, s as Placement, t as ObjectLink, u as providedApp } from "./object-link-pdzpEY0s.js";
3
3
  import { Effect, Option } from "effect";
4
4
  import { HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
5
5
  import { securityHeaders, streamPath } from "@streamsy/core/http";
@@ -11,6 +11,10 @@ const router = (options) => {
11
11
  return Effect.gen(function* () {
12
12
  const request = yield* HttpServerRequest.HttpServerRequest;
13
13
  const pathname = new URL(request.url, "https://streamsy.internal").pathname;
14
+ if (isReserved(pathname)) return HttpServerResponse.empty({
15
+ status: 404,
16
+ headers: securityHeaders
17
+ });
14
18
  const streamPath = path.strip(pathname);
15
19
  if (!streamPath || streamPath === pathname) return HttpServerResponse.text(`Stream path required: ${path.requiredPathPattern()}`, {
16
20
  status: 400,
@@ -21,9 +25,17 @@ const router = (options) => {
21
25
  return yield* options.objects.getByName(child.name).fetch(request);
22
26
  });
23
27
  };
28
+ /**
29
+ * An `ObjectFetch` over an Alchemy Durable Object binding, for
30
+ * `ObjectStreams.layer` and `ObjectLink.client`.
31
+ */
32
+ const objectFetch = (objects) => Effect.fn("Alchemy.objectFetch")(function* (name, request) {
33
+ const response = yield* objects.getByName(name).fetch(HttpServerRequest.fromWeb(request)).pipe(Effect.mapError((cause) => objectTransportError(request, cause)));
34
+ return HttpServerResponse.toWeb(response);
35
+ });
24
36
  function objectHandlers(options) {
25
37
  return Effect.gen(function* () {
26
- const get = yield* acquireObject(options.layer, (context, alarm) => Effect.gen(function* () {
38
+ const get = yield* acquireObject(withLinks(options.layer, options.links), (context, alarm) => Effect.gen(function* () {
27
39
  return {
28
40
  fetch: (yield* HttpRouter.toHttpEffect(providedApp(options.app, context))).pipe(Effect.scoped, Effect.catch(HttpServerRespondable.toResponse)),
29
41
  alarm: alarm.pipe(Effect.provide(context))
@@ -35,4 +47,4 @@ function objectHandlers(options) {
35
47
  };
36
48
  });
37
49
  }
38
- export { Placement, Wakeup, Wakeups, alarmSlotLayer, objectHandlers, router, rule };
50
+ export { ObjectLink, ObjectStreams, Placement, Wakeup, Wakeups, alarmSlotLayer, objectFetch, objectHandlers, router, rule };
@@ -1,26 +1,32 @@
1
- import { a as alarmSlotLayer, n as WakeupProgress, r as Wakeups, t as Wakeup } from "./wakeup--kIoqiIE.js";
2
- import { i as ObjectServices, n as rule, r as ObjectApp, t as Placement } from "./placement-Cj3sd02S.js";
1
+ import { i as Wakeups, n as WakeupProgress, o as alarmSlotLayer, r as WakeupSource, t as Wakeup } from "./wakeup-BJGZnq4x.js";
2
+ import { a as ObjectLinkOptions, c as ObjectStreamsOptions, d as ObjectApp, i as ObjectLink, l as Placement, n as LinkClient, o as ObjectFetch, r as LinkRoutes, s as ObjectStreams, t as LinkAccess, u as rule } from "./object-link-B3cvdzGy.js";
3
3
  import { Layer } from "effect";
4
4
  import { Storage, StorageFault } from "@streamsy/core";
5
5
  import { DurableObject } from "cloudflare:workers";
6
6
  import { AlarmInvocationInfo, DurableObjectNamespace, DurableObjectState, ExportedHandler } from "@cloudflare/workers-types";
7
- interface ObjectConfiguration<Env, R = never> {
8
- readonly app: ObjectApp<R>;
9
- readonly wakeups?: ReadonlyArray<Wakeup<ObjectServices<R>>>;
10
- readonly layer: (state: DurableObjectState, env: Env) => Layer.Layer<Storage | R, StorageFault>;
7
+ interface ObjectConfiguration<Env, R = never, L = never> {
8
+ readonly app: ObjectApp<R | L>;
9
+ readonly wakeups?: ReadonlyArray<WakeupSource<Storage | R | L>>;
10
+ readonly layer: (state: DurableObjectState, env: Env) => Layer.Layer<Storage | R, StorageFault, L>;
11
+ /**
12
+ * Streams that live on other objects. Their services come from these links
13
+ * and never from `layer`. An object whose `layer` also provides a linked
14
+ * stream fails to start.
15
+ */
16
+ readonly links?: (env: Env) => ReadonlyArray<LinkClient<L>>;
11
17
  }
12
18
  interface ObjectInstance {
13
19
  fetch(request: Request): Promise<Response>;
14
20
  alarm(info?: AlarmInvocationInfo): Promise<void>;
15
21
  }
16
- declare class ObjectHost<Env, R> extends DurableObject<Env> {
22
+ declare class ObjectHost<Env, R, L> extends DurableObject<Env> {
17
23
  #private;
18
- constructor(state: DurableObjectState, env: Env, configuration: ObjectConfiguration<Env, R>);
24
+ constructor(state: DurableObjectState, env: Env, configuration: ObjectConfiguration<Env, R, L>);
19
25
  fetch(request: Request): Promise<Response>;
20
26
  alarm(_info?: AlarmInvocationInfo): Promise<void>;
21
27
  }
22
28
  export declare const StreamsyObject: {
23
- make: <Env = unknown, R = never>(configuration: ObjectConfiguration<Env, R>) => new (state: DurableObjectState, env: Env) => ObjectHost<Env, R>;
29
+ make: <Env = unknown, R = never, L = never>(configuration: ObjectConfiguration<Env, R, L>) => new (state: DurableObjectState, env: Env) => ObjectHost<Env, R, L>;
24
30
  };
25
31
  interface RouterOptions<Env> {
26
32
  readonly namespace: (env: Env) => DurableObjectNamespace;
@@ -28,4 +34,4 @@ interface RouterOptions<Env> {
28
34
  readonly prefix?: `/${string}`;
29
35
  }
30
36
  export declare const router: <Env>(options: RouterOptions<Env>) => ExportedHandler<Env>;
31
- export { type ObjectConfiguration, type ObjectInstance, Placement, type RouterOptions, Wakeup, type WakeupProgress, Wakeups, alarmSlotLayer, rule };
37
+ export { type LinkAccess, type LinkClient, type LinkRoutes, type ObjectConfiguration, type ObjectFetch, type ObjectInstance, ObjectLink, type ObjectLinkOptions, ObjectStreams, type ObjectStreamsOptions, Placement, type RouterOptions, Wakeup, type WakeupProgress, type WakeupSource, Wakeups, alarmSlotLayer, rule };
@@ -1,5 +1,5 @@
1
- import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-CcJE8k3i.js";
2
- import { a as acquireObject, i as rule, n as router, o as providedApp, r as Placement, s as unavailable } from "./router-C2p1T25K.js";
1
+ import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-Cb5DAcpF.js";
2
+ import { c as rule, d as unavailable, f as withLinks, l as acquireObject, n as ObjectStreams, o as router, s as Placement, t as ObjectLink, u as providedApp } from "./object-link-pdzpEY0s.js";
3
3
  import { Context, Effect, Layer, ManagedRuntime, Option } from "effect";
4
4
  import { HttpRouter, HttpServerResponse } from "effect/http";
5
5
  import { DurableObject } from "cloudflare:workers";
@@ -9,7 +9,7 @@ var ObjectHost = class extends DurableObject {
9
9
  #runtime;
10
10
  constructor(state, env, configuration) {
11
11
  super(state, env);
12
- this.#runtime = ManagedRuntime.make(Layer.effect(ObjectOwner, Effect.map(acquireObject(Layer.suspend(() => Layer.merge(configuration.layer(state, env), alarmSlotLayer(state.storage))), (context, alarm) => Effect.gen(function* () {
12
+ this.#runtime = ManagedRuntime.make(Layer.effect(ObjectOwner, Effect.map(acquireObject(Layer.suspend(() => Layer.merge(withLinks(configuration.layer(state, env), configuration.links?.(env)), alarmSlotLayer(state.storage))), (context, alarm) => Effect.gen(function* () {
13
13
  const web = HttpRouter.toWebHandler(providedApp(configuration.app, context), { disableLogger: true });
14
14
  yield* Effect.addFinalizer(() => Effect.promise(web.dispose));
15
15
  return {
@@ -36,4 +36,4 @@ const StreamsyObject = { make: (configuration) => class extends ObjectHost {
36
36
  super(state, env, configuration);
37
37
  }
38
38
  } };
39
- export { Placement, StreamsyObject, Wakeup, Wakeups, alarmSlotLayer, router, rule };
39
+ export { ObjectLink, ObjectStreams, Placement, StreamsyObject, Wakeup, Wakeups, alarmSlotLayer, router, rule };
@@ -0,0 +1,118 @@
1
+ import { i as Wakeups } from "./wakeup-BJGZnq4x.js";
2
+ import { Context, Effect, Layer, Option, Scope } from "effect";
3
+ import { HttpClientError, HttpRouter, HttpServerRespondable, HttpServerResponse } from "effect/http";
4
+ import { Storage, StorageFault, Streamsy } from "@streamsy/core";
5
+ import { DurableObjectNamespace } from "@cloudflare/workers-types";
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
+ interface Placement {
9
+ readonly name: (streamPath: string) => string;
10
+ }
11
+ /** The parse side of a stream, which is all a placement rule reads. */
12
+ interface Parser<Params> {
13
+ parse(id: string): Option.Option<Params>;
14
+ }
15
+ /**
16
+ * One rule in a stream placement: a stream and the object name its
17
+ * addresses belong to.
18
+ *
19
+ * The owner takes the same parameters its own stream decodes, so the object name
20
+ * and the storage routing agree by construction. `owner` uses method syntax on
21
+ * purpose: it gives the parameter its contextually typed shape at the call site
22
+ * and keeps the rule assignable to the erased table.
23
+ */
24
+ interface OwnerRule<Params> {
25
+ readonly stream: Parser<Params>;
26
+ /** Names the object that owns every stream matching `stream`. */
27
+ owner(params: Params): string;
28
+ }
29
+ /**
30
+ * A rule after its parameter type is erased. `byRoute` reads each rule only
31
+ * through that rule's own `parse`, so the erased parameter is never observed.
32
+ */
33
+ interface ErasedOwnerRule {
34
+ readonly stream: Parser<unknown>;
35
+ owner(params: never): string;
36
+ }
37
+ /**
38
+ * Declare one placement rule with its stream's parameters in scope.
39
+ *
40
+ * The helper exists so the owner parameter is typed and checked. A rule passed
41
+ * to `Placement.byRoute` without it would see the erased parameter set instead.
42
+ */
43
+ declare const rule: <Params>(spec: OwnerRule<Params>) => ErasedOwnerRule;
44
+ declare const Placement: {
45
+ readonly byStream: () => Placement;
46
+ readonly byKey: (key: (streamPath: string) => string) => Placement;
47
+ /**
48
+ * The first matching rule names the object.
49
+ *
50
+ * A path that no rule matches yields `""`, which the router turns into
51
+ * `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
52
+ * stream cannot decode a segment does not own that path, so the table falls
53
+ * through to the next rule.
54
+ */
55
+ readonly byRoute: (rules: ReadonlyArray<ErasedOwnerRule>) => Placement;
56
+ };
57
+ /** Sends one request to the object with this name. */
58
+ type ObjectFetch = (name: string, request: Request) => Effect.Effect<Response, HttpClientError.HttpClientError>;
59
+ interface ObjectStreamsOptions {
60
+ /** The namespace of the owner objects, or a function that sends a request to one object by name. */
61
+ readonly objects: DurableObjectNamespace | ObjectFetch;
62
+ /** Names the owner object of each stream id. Use the same value that the app gives to `router`. */
63
+ readonly placement: Placement;
64
+ /** The mount prefix on the owner object, for example "/streams" or "/__wake". */
65
+ readonly prefix: `/${string}`;
66
+ /** Extra headers on each request. */
67
+ readonly headers?: Readonly<Record<string, string>>;
68
+ }
69
+ /**
70
+ * A `RemoteStreams` layer whose requests go to the object that owns each
71
+ * stream, and never to the network. The owner side is always a Streamsy
72
+ * `Http.routes` mount, so the client declares producer and expected-offset support.
73
+ */
74
+ declare function layer<const Keys extends ReadonlyArray<Streamsy.Any>>(group: Streamsy.Group<Keys>, options: ObjectStreamsOptions): Layer.Layer<Streamsy.Services<Streamsy.Group<Keys>>>;
75
+ declare const ObjectStreams: {
76
+ readonly layer: typeof layer;
77
+ };
78
+ /** What callers may do on a link mount. It applies to wake streams too. */
79
+ type LinkAccess = "read" | "append" | "full";
80
+ interface ObjectLinkOptions<Keys extends ReadonlyArray<Streamsy.Any>> {
81
+ /** The streams that the owner serves to other objects. */
82
+ readonly group: Streamsy.Group<Keys>;
83
+ /** The internal mount. It must start with "/__", so that no package router forwards it. */
84
+ readonly prefix: `/__${string}`;
85
+ /** Names the owner object of each stream id. */
86
+ readonly placement: Placement;
87
+ readonly access: LinkAccess;
88
+ }
89
+ /**
90
+ * The caller side of a link. Return it from the caller object's `links`. The
91
+ * host refuses an object whose own layer also provides one of `streams`.
92
+ */
93
+ interface LinkClient<I> {
94
+ readonly streams: ReadonlyArray<Streamsy.Any>;
95
+ readonly layer: Layer.Layer<I>;
96
+ }
97
+ /** The owner mount, typed as `Http.routes` types it. */
98
+ type LinkRoutes<I> = Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", Exclude<I, HttpRouter.Provided>>>;
99
+ interface ObjectLink<Keys extends ReadonlyArray<Streamsy.Any>> {
100
+ readonly group: Streamsy.Group<Keys>;
101
+ readonly prefix: `/__${string}`;
102
+ /** The owner side. Add it to the owner object's `app`. */
103
+ readonly routes: LinkRoutes<Streamsy.Services<Streamsy.Group<Keys>>>;
104
+ /**
105
+ * The caller side. `objects` is the owner namespace, or an `ObjectFetch` such
106
+ * as Alchemy's `objectFetch(binding)`. Return the result from the caller's `links`.
107
+ */
108
+ readonly client: (objects: DurableObjectNamespace | ObjectFetch) => LinkClient<Streamsy.Services<Streamsy.Group<Keys>>>;
109
+ }
110
+ /**
111
+ * One value for both sides of an internal mount between objects: the owner
112
+ * mount and the caller client read the same group, prefix and access.
113
+ */
114
+ declare const make: <const Keys extends ReadonlyArray<Streamsy.Any>>(options: ObjectLinkOptions<Keys>) => ObjectLink<Keys>;
115
+ declare const ObjectLink: {
116
+ readonly make: typeof make;
117
+ };
118
+ export { ObjectLinkOptions as a, ObjectStreamsOptions as c, ObjectApp as d, ObjectLink as i, Placement as l, LinkClient as n, ObjectFetch as o, LinkRoutes as r, ObjectStreams as s, LinkAccess as t, rule as u };
@@ -0,0 +1,230 @@
1
+ import { i as makeWakeups, r as Wakeups, t as RequestTouch } from "./wakeup-Cb5DAcpF.js";
2
+ import { Array, Context, Effect, Exit, Layer, Option, Predicate, Scope } from "effect";
3
+ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
4
+ import { Http, StorageFault } from "@streamsy/core";
5
+ import { securityHeaders, streamPath } from "@streamsy/core/http";
6
+ import { RemoteStreams } from "@streamsy/core/remote";
7
+ // src/cloudflare/links.ts
8
+ const clashMessage = (stream) => `${stream.key} is linked to another object, but this object's layer also provides it. Remove it from the storage group.`;
9
+ /**
10
+ * Puts the link layers under the object's own layer and beside it. The own
11
+ * layer sees the linked services at start, and the object sees them at run
12
+ * time. An own layer that also provides a linked stream fails with a
13
+ * `StorageFault`, because its rows would go to a local stream that no
14
+ * client reads.
15
+ */
16
+ function withLinks(own, links) {
17
+ const clients = links ?? [];
18
+ const streams = Array.flatMap(clients, (link) => link.streams);
19
+ const linked = Layer.mergeAll(Layer.empty, ...Array.map(clients, (link) => link.layer));
20
+ const checked = Layer.provide(own, linked).pipe(Layer.tap((local) => Option.match(Array.findFirst(streams, (stream) => Option.isSome(Context.getOption(local, stream))), {
21
+ onNone: () => Effect.void,
22
+ onSome: (stream) => Effect.fail(new StorageFault({
23
+ operation: "streamsy.links",
24
+ message: clashMessage(stream),
25
+ retryable: false
26
+ }))
27
+ })));
28
+ return Layer.merge(checked, linked);
29
+ }
30
+ // src/cloudflare/host-program.ts
31
+ /**
32
+ * Reconciliation belongs to the mutation's finalizer. A request interruption
33
+ * can be pending when the mutation's own uninterruptible region ends, so a
34
+ * second sequential uninterruptible effect is not sufficient. A failed
35
+ * reconciliation is logged and does not replace the mutation's result.
36
+ */
37
+ const withMutationReconciliation = (effect, reconcile) => effect.pipe(Effect.ensuring(reconcile.pipe(Effect.uninterruptible, Effect.catchCause((cause) => Effect.logError("Wakeup reconciliation failed", cause)))));
38
+ // src/cloudflare/object-runtime.ts
39
+ /** Both object hosts install this middleware, including around user routes. */
40
+ function objectApp(app, context) {
41
+ const middleware = HttpRouter.middleware((handler) => Effect.gen(function* () {
42
+ const request = yield* HttpServerRequest.HttpServerRequest;
43
+ const marker = { touched: false };
44
+ const wakeups = Context.get(context, Wakeups);
45
+ return yield* withMutationReconciliation(handler, Effect.suspend(() => [
46
+ "PUT",
47
+ "POST",
48
+ "DELETE"
49
+ ].includes(request.method) || marker.touched ? wakeups.reconcile : Effect.void)).pipe(Effect.catchDefect(() => Effect.succeed(HttpServerResponse.text("Internal server error", {
50
+ status: 500,
51
+ headers: Http.securityHeaders
52
+ }))), Effect.provideService(RequestTouch, marker));
53
+ }).pipe(Effect.provide(context)), { global: true });
54
+ return Layer.merge(app, middleware);
55
+ }
56
+ const unavailable = () => HttpServerResponse.text("Storage unavailable", {
57
+ status: 503,
58
+ headers: {
59
+ ...Http.securityHeaders,
60
+ "retry-after": "1"
61
+ }
62
+ });
63
+ /** One lazy successful acquisition per object owner. Failed attempts close before retry. */
64
+ function acquireObject(layer, compile, wakeups) {
65
+ return Effect.gen(function* () {
66
+ const owner = yield* Scope.Scope;
67
+ return yield* Effect.gen(function* () {
68
+ const attempt = yield* Scope.fork(owner);
69
+ return yield* Effect.gen(function* () {
70
+ const context = yield* Layer.buildWithScope(layer, attempt);
71
+ const scheduler = yield* makeWakeups(context, wakeups).pipe(Effect.provideService(Scope.Scope, attempt));
72
+ return yield* compile(scheduler.context, scheduler.alarm).pipe(Effect.provideService(Scope.Scope, attempt));
73
+ }).pipe(Effect.onExit((exit) => Exit.isFailure(exit) ? Scope.close(attempt, exit) : Effect.void));
74
+ }).pipe(Effect.cachedWithTTL((exit) => Exit.isSuccess(exit) ? "Infinity" : 0));
75
+ });
76
+ }
77
+ /** Request markers are all supplied by this exact object context. */
78
+ function providedApp(app, context) {
79
+ return objectApp(app, context).pipe(HttpRouter.provideRequest(Layer.succeedContext(context)));
80
+ }
81
+ // src/cloudflare/placement.ts
82
+ /**
83
+ * Declare one placement rule with its stream's parameters in scope.
84
+ *
85
+ * The helper exists so the owner parameter is typed and checked. A rule passed
86
+ * to `Placement.byRoute` without it would see the erased parameter set instead.
87
+ */
88
+ const rule = (spec) => spec;
89
+ const Placement = {
90
+ byStream: () => ({ name: (streamPath) => streamPath }),
91
+ byKey: (key) => ({ name: key }),
92
+ /**
93
+ * The first matching rule names the object.
94
+ *
95
+ * A path that no rule matches yields `""`, which the router turns into
96
+ * `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
97
+ * stream cannot decode a segment does not own that path, so the table falls
98
+ * through to the next rule.
99
+ */
100
+ byRoute: (rules) => ({ name: (streamPath) => {
101
+ for (const entry of rules) {
102
+ const params = entry.stream.parse(streamPath);
103
+ if (Option.isNone(params)) continue;
104
+ return entry.owner(params.value);
105
+ }
106
+ return "";
107
+ } })
108
+ };
109
+ // src/cloudflare/router.ts
110
+ const badRequest = (message) => new Response(message, {
111
+ status: 400,
112
+ headers: securityHeaders
113
+ });
114
+ const internalError = () => new Response("Internal server error", {
115
+ status: 500,
116
+ headers: securityHeaders
117
+ });
118
+ const notFound = () => new Response(null, {
119
+ status: 404,
120
+ headers: securityHeaders
121
+ });
122
+ /**
123
+ * Paths whose first segment starts with `__` belong to object links and
124
+ * other internal mounts. No package router forwards them to an object.
125
+ */
126
+ const isReserved = (pathname) => pathname.startsWith("/__");
127
+ const invalidPlacement = () => badRequest("Invalid placement key");
128
+ const resolvePlacement = (placement, streamPath) => {
129
+ try {
130
+ const candidate = placement.name(streamPath);
131
+ const name = String(candidate);
132
+ return candidate === name && name.length > 0 ? {
133
+ ok: true,
134
+ name
135
+ } : {
136
+ ok: false,
137
+ response: invalidPlacement()
138
+ };
139
+ } catch {
140
+ return {
141
+ ok: false,
142
+ response: internalError()
143
+ };
144
+ }
145
+ };
146
+ const router = (options) => {
147
+ const path = streamPath(options.prefix);
148
+ const placement = options.placement ?? Placement.byStream();
149
+ return { fetch(request, env) {
150
+ const url = new URL(request.url);
151
+ if (isReserved(url.pathname)) return notFound();
152
+ const streamPath = path.strip(url.pathname);
153
+ if (!streamPath || streamPath === url.pathname) return badRequest(`Stream path required: ${path.requiredPathPattern()}`);
154
+ const child = resolvePlacement(placement, streamPath);
155
+ if (!child.ok) return child.response;
156
+ const namespace = options.namespace(env);
157
+ return namespace.get(namespace.idFromName(child.name)).fetch(request);
158
+ } };
159
+ };
160
+ // src/cloudflare/object-streams.ts
161
+ /** Translate a host transport error at the Web request boundary. */
162
+ const objectTransportError = (request, cause) => new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
163
+ request: HttpClientRequest.fromWeb(request),
164
+ cause
165
+ }) });
166
+ /** The host is never resolved. It only makes a valid base URL for `RemoteStreams`. */
167
+ const base = "https://streamsy.internal";
168
+ /** A namespace becomes "send to the object with this name". */
169
+ const toObjectFetch = (objects) => Predicate.isFunction(objects) ? objects : (name, request) => Effect.tryPromise({
170
+ try: () => Promise.resolve(objects.get(objects.idFromName(name)).fetch(request)),
171
+ catch: (cause) => objectTransportError(request, cause)
172
+ });
173
+ /** An HTTP client that calls `fetch` in place of the network. */
174
+ const stubClient = (send) => Layer.succeed(HttpClient.HttpClient, HttpClient.make((request, _url, signal) => HttpClientRequest.toWeb(request, { signal }).pipe(Effect.mapError((cause) => new HttpClientError.HttpClientError({ reason: new HttpClientError.InvalidUrlError({
175
+ request,
176
+ cause
177
+ }) })), Effect.flatMap(send), Effect.map((response) => HttpClientResponse.fromWeb(request, response)))));
178
+ /**
179
+ * The `fetch` that `ObjectStreams` gives its HTTP client. A path outside the
180
+ * prefix, or a stream outside the group, answers 404 here and calls no
181
+ * object. A bad placement answers as the public router does.
182
+ */
183
+ const objectRouter = (group, options) => {
184
+ const path = streamPath(options.prefix);
185
+ const send = toObjectFetch(options.objects);
186
+ return Effect.fn("ObjectStreams.route")(function* (request) {
187
+ const pathname = new URL(request.url).pathname;
188
+ const id = path.strip(pathname);
189
+ if (!id || id === pathname || !Array.some(group.streams, (stream) => stream.match(id))) return new Response(null, { status: 404 });
190
+ const owner = resolvePlacement(options.placement, id);
191
+ return owner.ok ? yield* send(owner.name, request) : owner.response;
192
+ });
193
+ };
194
+ function layer(group, options) {
195
+ return RemoteStreams.layer(group, {
196
+ baseUrl: `${base}${options.prefix}`,
197
+ headers: options.headers ?? {},
198
+ capabilities: {
199
+ producer: true,
200
+ expectedOffset: true
201
+ }
202
+ }).pipe(Layer.provide(stubClient(objectRouter(group, options))), Layer.orDie);
203
+ }
204
+ const ObjectStreams = { layer };
205
+ // src/cloudflare/object-link.ts
206
+ /**
207
+ * One value for both sides of an internal mount between objects: the owner
208
+ * mount and the caller client read the same group, prefix and access.
209
+ */
210
+ const make = (options) => {
211
+ if (!/^\/__[^/]/.test(options.prefix)) throw new RangeError(`An object link prefix must start with "/__", got "${options.prefix}"`);
212
+ return {
213
+ group: options.group,
214
+ prefix: options.prefix,
215
+ routes: Http.routes(options.group, {
216
+ prefix: options.prefix,
217
+ access: options.access
218
+ }),
219
+ client: (objects) => ({
220
+ streams: options.group.streams,
221
+ layer: ObjectStreams.layer(options.group, {
222
+ objects,
223
+ placement: options.placement,
224
+ prefix: options.prefix
225
+ })
226
+ })
227
+ };
228
+ };
229
+ const ObjectLink = { make };
230
+ export { resolvePlacement as a, rule as c, unavailable as d, withLinks as f, isReserved as i, acquireObject as l, ObjectStreams as n, router as o, objectTransportError as r, Placement as s, ObjectLink as t, providedApp as u };
@@ -1,4 +1,4 @@
1
- import { Context, Duration, Effect, Layer, Option } from "effect";
1
+ import { Context, Effect, Layer, Option, Schedule, Scope } from "effect";
2
2
  import { Storage, StorageFault } from "@streamsy/core";
3
3
  import { DurableObjectStorage } from "@cloudflare/workers-types";
4
4
  declare const AlarmSlot_base: Context.ServiceClass<AlarmSlot, "@streamsy/serve/cloudflare/AlarmSlot", {
@@ -24,11 +24,16 @@ interface Wakeup<R = never> {
24
24
  * name is not reentrant, so a run must not call its own wakeup's `run`.
25
25
  */
26
26
  readonly run: Effect.Effect<void | WakeupProgress, unknown, R>;
27
- readonly retry?: {
28
- readonly initial?: Duration.Input;
29
- readonly max?: Duration.Input;
30
- };
27
+ /** Alarm retries use this schedule's delays without sleeping in an alarm. */
28
+ readonly retry?: Schedule.Schedule<unknown>;
29
+ /**
30
+ * Units of work that failed and wait on their own backoff, for `status`.
31
+ * A durable `ProjectionWakeup` counts its deferred bindings here.
32
+ */
33
+ readonly deferred?: Effect.Effect<number, StorageFault, R>;
31
34
  }
35
+ /** The host acquires effectful wakeups in the same scope as its storage layer. */
36
+ type WakeupSource<R = never> = Wakeup<R | Wakeups> | Effect.Effect<Wakeup<R | Wakeups>, never, R | Scope.Scope>;
32
37
  declare const Wakeup: {
33
38
  make: <R>(wakeup: Wakeup<R>) => Wakeup<R>;
34
39
  };
@@ -46,7 +51,9 @@ declare const Wakeups_base: Context.ServiceClass<Wakeups, "@streamsy/serve/cloud
46
51
  readonly failures: number;
47
52
  /** The backoff floor. The wakeup does not run before this instant. */
48
53
  readonly retryAt: Option.Option<number>;
54
+ /** Units that wait on their own backoff, such as failed projection bindings. */
55
+ readonly deferred: number;
49
56
  }>>;
50
57
  }>;
51
58
  declare class Wakeups extends Wakeups_base {}
52
- export { alarmSlotLayer as a, AlarmSlot as i, WakeupProgress as n, Wakeups as r, Wakeup as t };
59
+ export { AlarmSlot as a, Wakeups as i, WakeupProgress as n, alarmSlotLayer as o, WakeupSource as r, Wakeup as t };
@@ -1,4 +1,4 @@
1
- import { Array, Clock, Context, Duration, Effect, Exit, Layer, Option, Predicate, Semaphore } from "effect";
1
+ import { Array, Clock, Context, Duration, Effect, Exit, Layer, Option, Predicate, Pull, Schedule, Semaphore } from "effect";
2
2
  import { Storage } from "@streamsy/core";
3
3
  import { Protocol } from "@streamsy/core/protocol";
4
4
  // src/cloudflare/alarm.ts
@@ -9,6 +9,7 @@ const alarmSlotLayer = (storage) => Layer.succeed(AlarmSlot, {
9
9
  clear: Effect.promise(() => storage.deleteAlarm())
10
10
  });
11
11
  // src/cloudflare/wakeup.ts
12
+ const defaultRetry = Schedule.exponential("1 second").pipe(Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.hours(1)))));
12
13
  const Permits = Context.Reference("@streamsy/serve/cloudflare/WakeupPermits", { defaultValue: () => void 0 });
13
14
  const Guarded = Symbol.for("@streamsy/serve/cloudflare/WakeupPermit");
14
15
  const reportsMore = (value) => Predicate.hasProperty(value, "more") && value.more;
@@ -34,7 +35,8 @@ const expiry = Wakeup.make({
34
35
  });
35
36
  /** Internal object owner. Its context closes over the same services as fetch. */
36
37
  const makeWakeups = (context, definitions = []) => Effect.gen(function* () {
37
- const wakeups = Array.map([expiry, ...definitions], (wakeup) => Wakeup.make(wakeup));
38
+ const acquired = yield* Effect.forEach(definitions, (definition) => Effect.isEffect(definition) ? definition.pipe(Effect.provide(context)) : Effect.succeed(definition));
39
+ const wakeups = Array.map([expiry, ...acquired], (wakeup) => Wakeup.make(wakeup));
38
40
  const names = /* @__PURE__ */ new Set();
39
41
  for (const wakeup of wakeups) {
40
42
  if (names.has(wakeup.name)) return yield* Effect.die(/* @__PURE__ */ new Error(`Duplicate wakeup name: ${wakeup.name}`));
@@ -43,32 +45,47 @@ const makeWakeups = (context, definitions = []) => Effect.gen(function* () {
43
45
  const permits = new Map(Array.map(wakeups, (wakeup) => [wakeup.name, Semaphore.makeUnsafe(1)]));
44
46
  const reconcilePermit = yield* Semaphore.make(1);
45
47
  const turnPermit = yield* Semaphore.make(1);
46
- const states = Array.map(wakeups, (wakeup) => ({
47
- wakeup,
48
- failures: 0,
49
- floor: 0,
50
- initial: Duration.toMillis(wakeup.retry?.initial ?? "1 second"),
51
- max: Duration.toMillis(wakeup.retry?.max ?? "1 hour")
48
+ const states = yield* Effect.forEach(wakeups, (wakeup) => Effect.gen(function* () {
49
+ const policy = wakeup.retry ?? defaultRetry;
50
+ return {
51
+ wakeup,
52
+ failures: 0,
53
+ floor: 0,
54
+ exhausted: false,
55
+ policy,
56
+ step: yield* Schedule.toStep(policy)
57
+ };
52
58
  }));
53
59
  const slot = Context.get(context, AlarmSlot);
54
60
  const provided = () => Context.add(Context.add(context, Permits, permits), Wakeups, service);
55
- const backOff = (state, at) => {
61
+ const backOff = (state, at) => Effect.gen(function* () {
56
62
  state.failures += 1;
57
- state.floor = at + Math.min(state.max, state.initial * 2 ** (state.failures - 1));
58
- };
59
- const recover = (state) => {
63
+ const delay = yield* state.step(at, void 0).pipe(Effect.map(([, duration]) => Option.some(duration)), Pull.catchDone(() => Effect.succeedNone));
64
+ state.exhausted = Option.isNone(delay);
65
+ state.floor = Option.isSome(delay) ? at + Duration.toMillis(delay.value) : 0;
66
+ });
67
+ const recover = (state) => Effect.gen(function* () {
60
68
  state.failures = 0;
61
69
  state.floor = 0;
62
- };
63
- const status = Effect.forEach(states, (state) => state.wakeup.next.pipe(Effect.map((next) => ({
70
+ state.exhausted = false;
71
+ state.step = yield* Schedule.toStep(state.policy);
72
+ });
73
+ const status = Effect.forEach(states, (state) => Effect.all({
74
+ next: state.wakeup.next,
75
+ deferred: state.wakeup.deferred ?? Effect.succeed(0)
76
+ }).pipe(Effect.map(({ next, deferred }) => ({
64
77
  name: state.wakeup.name,
65
78
  next,
66
79
  failures: state.failures,
67
- retryAt: state.floor > 0 ? Option.some(state.floor) : Option.none()
80
+ retryAt: state.floor > 0 ? Option.some(state.floor) : Option.none(),
81
+ deferred
68
82
  }))));
69
- const deadlineOf = (state, now) => state.wakeup.next.pipe(Effect.map(Option.map((next) => Math.max(next, state.floor))), Effect.catchCause((cause) => {
70
- if (state.floor <= now) backOff(state, now);
71
- return Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause).pipe(Effect.as(Option.some(state.floor)));
83
+ const deadlineOf = (state, now) => state.exhausted ? Effect.succeedNone : state.wakeup.next.pipe(Effect.map(Option.map((next) => Math.max(next, state.floor))), Effect.catchCause((cause) => {
84
+ return Effect.gen(function* () {
85
+ if (state.floor <= now) yield* backOff(state, now);
86
+ yield* Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause);
87
+ return state.exhausted ? Option.none() : Option.some(state.floor);
88
+ });
72
89
  }));
73
90
  const reconcileWith = (force) => reconcilePermit.withPermit(Effect.gen(function* () {
74
91
  const now = yield* Clock.currentTimeMillis;
@@ -95,22 +112,21 @@ const makeWakeups = (context, definitions = []) => Effect.gen(function* () {
95
112
  const now = yield* Clock.currentTimeMillis;
96
113
  yield* slot.arm(now + 3e4);
97
114
  for (const state of states) yield* Effect.gen(function* () {
98
- const at = yield* Clock.currentTimeMillis;
99
- if (state.floor > at) return;
115
+ if (state.exhausted || state.floor > (yield* Clock.currentTimeMillis)) return;
100
116
  const next = yield* state.wakeup.next;
117
+ const at = yield* Clock.currentTimeMillis;
101
118
  if (Option.isNone(next) || next.value > at) return;
102
119
  const exit = yield* Effect.exit(state.wakeup.run);
103
120
  if (Exit.isFailure(exit)) {
104
- backOff(state, yield* Clock.currentTimeMillis);
121
+ yield* backOff(state, yield* Clock.currentTimeMillis);
105
122
  yield* Effect.logError(`Wakeup ${state.wakeup.name} failed`, exit.cause);
106
123
  return;
107
124
  }
108
125
  const after = yield* state.wakeup.next;
109
- if (!reportsMore(exit.value) && Option.isSome(after) && after.value <= at) backOff(state, yield* Clock.currentTimeMillis);
110
- else recover(state);
126
+ if (!reportsMore(exit.value) && Option.isSome(after) && after.value <= at) yield* backOff(state, yield* Clock.currentTimeMillis);
127
+ else yield* recover(state);
111
128
  }).pipe(Effect.catchCause((cause) => Clock.currentTimeMillis.pipe(Effect.flatMap((at) => {
112
- backOff(state, at);
113
- return Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause);
129
+ return backOff(state, at).pipe(Effect.andThen(Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause)));
114
130
  }))));
115
131
  }).pipe(Effect.ensuring(reconcileWith(true).pipe(Effect.catchCause((cause) => Effect.logError("Wakeup reconciliation failed", cause)))), Effect.catchCause((cause) => Effect.logError("Wakeup alarm failed", cause))));
116
132
  const complete = provided();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamsy/serve",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Effect HttpRouter routes, Bun, Node, Deno and Cloudflare hosts, and durable action delivery.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -87,7 +87,7 @@
87
87
  "test:dialects": "bun --bun vitest run test/action/outbox-dialects.test.ts"
88
88
  },
89
89
  "dependencies": {
90
- "@streamsy/core": "^0.5.0"
90
+ "@streamsy/core": "^0.7.0"
91
91
  },
92
92
  "devDependencies": {
93
93
  "@cloudflare/workers-types": "4.20251228.0",
@@ -98,7 +98,7 @@
98
98
  "@effect/sql-pg": "4.0.5",
99
99
  "@effect/sql-sqlite-do": "4.0.5",
100
100
  "@effect/vitest": "4.0.5",
101
- "@streamsy/storage": "0.5.0",
101
+ "@streamsy/storage": "0.7.0",
102
102
  "@types/bun": "^1.4.3",
103
103
  "alchemy": "2.0.0-beta.83",
104
104
  "effect": "4.0.5",
@@ -1,56 +0,0 @@
1
- import { r as Wakeups } from "./wakeup--kIoqiIE.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
- type ObjectServices<R = never> = Storage | Wakeups | R;
6
- type ObjectApp<R = never> = Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ObjectServices<R>> | HttpRouter.Request.From<"Error", HttpServerRespondable.Respondable>>;
7
- interface Placement {
8
- readonly name: (streamPath: string) => string;
9
- }
10
- /** The parse side of a stream, which is all a placement rule reads. */
11
- interface Parser<Params> {
12
- parse(id: string): Option.Option<Params>;
13
- }
14
- /**
15
- * One rule in a stream placement: a stream and the object name its
16
- * addresses belong to.
17
- *
18
- * The owner takes the same parameters its own stream decodes, so the object name
19
- * and the storage routing agree by construction. `owner` uses method syntax on
20
- * purpose: it gives the parameter its contextually typed shape at the call site
21
- * and keeps the rule assignable to the erased table.
22
- */
23
- interface OwnerRule<Params> {
24
- readonly stream: Parser<Params>;
25
- /** Names the object that owns every stream matching `stream`. */
26
- owner(params: Params): string;
27
- }
28
- /**
29
- * A rule after its parameter type is erased. `byRoute` reads each rule only
30
- * through that rule's own `parse`, so the erased parameter is never observed.
31
- */
32
- interface ErasedOwnerRule {
33
- readonly stream: Parser<unknown>;
34
- owner(params: never): string;
35
- }
36
- /**
37
- * Declare one placement rule with its stream's parameters in scope.
38
- *
39
- * The helper exists so the owner parameter is typed and checked. A rule passed
40
- * to `Placement.byRoute` without it would see the erased parameter set instead.
41
- */
42
- declare const rule: <Params>(spec: OwnerRule<Params>) => ErasedOwnerRule;
43
- declare const Placement: {
44
- readonly byStream: () => Placement;
45
- readonly byKey: (key: (streamPath: string) => string) => Placement;
46
- /**
47
- * The first matching rule names the object.
48
- *
49
- * A path that no rule matches yields `""`, which the router turns into
50
- * `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
51
- * stream cannot decode a segment does not own that path, so the table falls
52
- * through to the next rule.
53
- */
54
- readonly byRoute: (rules: ReadonlyArray<ErasedOwnerRule>) => Placement;
55
- };
56
- export { ObjectServices as i, rule as n, ObjectApp as r, Placement as t };
@@ -1,126 +0,0 @@
1
- import { i as makeWakeups, r as Wakeups, t as RequestTouch } from "./wakeup-CcJE8k3i.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
- // 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
- // src/cloudflare/object-runtime.ts
15
- /** Both object hosts install this middleware, including around user routes. */
16
- function objectApp(app, context) {
17
- const middleware = HttpRouter.middleware((handler) => Effect.gen(function* () {
18
- const request = yield* HttpServerRequest.HttpServerRequest;
19
- const marker = { touched: false };
20
- const wakeups = Context.get(context, Wakeups);
21
- return yield* withMutationReconciliation(handler, Effect.suspend(() => [
22
- "PUT",
23
- "POST",
24
- "DELETE"
25
- ].includes(request.method) || marker.touched ? wakeups.reconcile : Effect.void)).pipe(Effect.catchDefect(() => Effect.succeed(HttpServerResponse.text("Internal server error", {
26
- status: 500,
27
- headers: Http.securityHeaders
28
- }))), Effect.provideService(RequestTouch, marker));
29
- }).pipe(Effect.provide(context)), { global: true });
30
- return Layer.merge(app, middleware);
31
- }
32
- const unavailable = () => HttpServerResponse.text("Storage unavailable", {
33
- status: 503,
34
- headers: {
35
- ...Http.securityHeaders,
36
- "retry-after": "1"
37
- }
38
- });
39
- /** One lazy successful acquisition per object owner. Failed attempts close before retry. */
40
- function acquireObject(layer, compile, wakeups) {
41
- return Effect.gen(function* () {
42
- const owner = yield* Scope.Scope;
43
- return yield* Effect.gen(function* () {
44
- const attempt = yield* Scope.fork(owner);
45
- return yield* Effect.gen(function* () {
46
- const context = yield* Layer.buildWithScope(layer, attempt);
47
- const scheduler = yield* makeWakeups(context, wakeups);
48
- return yield* compile(scheduler.context, scheduler.alarm).pipe(Effect.provideService(Scope.Scope, attempt));
49
- }).pipe(Effect.onExit((exit) => Exit.isFailure(exit) ? Scope.close(attempt, exit) : Effect.void));
50
- }).pipe(Effect.cachedWithTTL((exit) => Exit.isSuccess(exit) ? "Infinity" : 0));
51
- });
52
- }
53
- /** Request markers are all supplied by this exact object context. */
54
- function providedApp(app, context) {
55
- return objectApp(app, context).pipe(HttpRouter.provideRequest(Layer.succeedContext(context)));
56
- }
57
- // src/cloudflare/placement.ts
58
- /**
59
- * Declare one placement rule with its stream's parameters in scope.
60
- *
61
- * The helper exists so the owner parameter is typed and checked. A rule passed
62
- * to `Placement.byRoute` without it would see the erased parameter set instead.
63
- */
64
- const rule = (spec) => spec;
65
- const Placement = {
66
- byStream: () => ({ name: (streamPath) => streamPath }),
67
- byKey: (key) => ({ name: key }),
68
- /**
69
- * The first matching rule names the object.
70
- *
71
- * A path that no rule matches yields `""`, which the router turns into
72
- * `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
73
- * stream cannot decode a segment does not own that path, so the table falls
74
- * through to the next rule.
75
- */
76
- byRoute: (rules) => ({ name: (streamPath) => {
77
- for (const entry of rules) {
78
- const params = entry.stream.parse(streamPath);
79
- if (Option.isNone(params)) continue;
80
- return entry.owner(params.value);
81
- }
82
- return "";
83
- } })
84
- };
85
- // src/cloudflare/router.ts
86
- const badRequest = (message) => new Response(message, {
87
- status: 400,
88
- headers: securityHeaders
89
- });
90
- const internalError = () => new Response("Internal server error", {
91
- status: 500,
92
- headers: securityHeaders
93
- });
94
- const invalidPlacement = () => badRequest("Invalid placement key");
95
- const resolvePlacement = (placement, streamPath) => {
96
- try {
97
- const candidate = placement.name(streamPath);
98
- const name = String(candidate);
99
- return candidate === name && name.length > 0 ? {
100
- ok: true,
101
- name
102
- } : {
103
- ok: false,
104
- response: invalidPlacement()
105
- };
106
- } catch {
107
- return {
108
- ok: false,
109
- response: internalError()
110
- };
111
- }
112
- };
113
- const router = (options) => {
114
- const path = streamPath(options.prefix);
115
- const placement = options.placement ?? Placement.byStream();
116
- return { fetch(request, env) {
117
- const url = new URL(request.url);
118
- const streamPath = path.strip(url.pathname);
119
- if (!streamPath || streamPath === url.pathname) return badRequest(`Stream path required: ${path.requiredPathPattern()}`);
120
- const child = resolvePlacement(placement, streamPath);
121
- if (!child.ok) return child.response;
122
- const namespace = options.namespace(env);
123
- return namespace.get(namespace.idFromName(child.name)).fetch(request);
124
- } };
125
- };
126
- export { acquireObject as a, rule as i, router as n, providedApp as o, Placement as r, unavailable as s, resolvePlacement as t };