@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 +38 -3
- package/dist/action/runtime.d.ts +1 -1
- package/dist/action/runtime.js +1 -1
- package/dist/alchemy.d.ts +14 -7
- package/dist/alchemy.js +16 -4
- package/dist/cloudflare.d.ts +16 -10
- package/dist/cloudflare.js +4 -4
- package/dist/object-link-B3cvdzGy.d.ts +118 -0
- package/dist/object-link-pdzpEY0s.js +230 -0
- package/dist/{wakeup--kIoqiIE.d.ts → wakeup-BJGZnq4x.d.ts} +13 -6
- package/dist/{wakeup-CcJE8k3i.js → wakeup-Cb5DAcpF.js} +41 -25
- package/package.json +3 -3
- package/dist/placement-Cj3sd02S.d.ts +0 -56
- package/dist/router-C2p1T25K.js +0 -126
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.
|
|
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).
|
package/dist/action/runtime.d.ts
CHANGED
|
@@ -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
|
|
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> {
|
package/dist/action/runtime.js
CHANGED
|
@@ -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-
|
|
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
|
|
2
|
-
import { i as
|
|
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<
|
|
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-
|
|
2
|
-
import { a as
|
|
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 };
|
package/dist/cloudflare.d.ts
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { i as
|
|
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<
|
|
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 };
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as alarmSlotLayer, n as Wakeup, r as Wakeups } from "./wakeup-
|
|
2
|
-
import {
|
|
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,
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|
|
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
|
|
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 =
|
|
47
|
-
wakeup
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 };
|
package/dist/router-C2p1T25K.js
DELETED
|
@@ -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 };
|