@streamsy/serve 0.4.0 → 0.6.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.
@@ -1,15 +1,15 @@
1
- import { 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
- //#region src/cloudflare/alarm.ts
4
+ // src/cloudflare/alarm.ts
5
5
  var AlarmSlot = class extends Context.Service()("@streamsy/serve/cloudflare/AlarmSlot") {};
6
6
  const alarmSlotLayer = (storage) => Layer.succeed(AlarmSlot, {
7
7
  current: Effect.promise(() => storage.getAlarm()).pipe(Effect.map(Option.fromNullishOr)),
8
8
  arm: (at) => Effect.promise(() => storage.setAlarm(at)),
9
9
  clear: Effect.promise(() => storage.deleteAlarm())
10
10
  });
11
- //#endregion
12
- //#region src/cloudflare/wakeup.ts
11
+ // src/cloudflare/wakeup.ts
12
+ const defaultRetry = Schedule.exponential("1 second").pipe(Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.hours(1)))));
13
13
  const Permits = Context.Reference("@streamsy/serve/cloudflare/WakeupPermits", { defaultValue: () => void 0 });
14
14
  const Guarded = Symbol.for("@streamsy/serve/cloudflare/WakeupPermit");
15
15
  const reportsMore = (value) => Predicate.hasProperty(value, "more") && value.more;
@@ -35,41 +35,53 @@ const expiry = Wakeup.make({
35
35
  });
36
36
  /** Internal object owner. Its context closes over the same services as fetch. */
37
37
  const makeWakeups = (context, definitions = []) => Effect.gen(function* () {
38
- const wakeups = [expiry, ...definitions].map(Wakeup.make);
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));
39
40
  const names = /* @__PURE__ */ new Set();
40
41
  for (const wakeup of wakeups) {
41
42
  if (names.has(wakeup.name)) return yield* Effect.die(/* @__PURE__ */ new Error(`Duplicate wakeup name: ${wakeup.name}`));
42
43
  names.add(wakeup.name);
43
44
  }
44
- const permits = new Map(wakeups.map((wakeup) => [wakeup.name, Semaphore.makeUnsafe(1)]));
45
+ const permits = new Map(Array.map(wakeups, (wakeup) => [wakeup.name, Semaphore.makeUnsafe(1)]));
45
46
  const reconcilePermit = yield* Semaphore.make(1);
46
47
  const turnPermit = yield* Semaphore.make(1);
47
- const states = wakeups.map((wakeup) => ({
48
- wakeup,
49
- failures: 0,
50
- floor: 0,
51
- initial: Duration.toMillis(wakeup.retry?.initial ?? "1 second"),
52
- 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
+ };
53
58
  }));
54
59
  const slot = Context.get(context, AlarmSlot);
55
60
  const provided = () => Context.add(Context.add(context, Permits, permits), Wakeups, service);
56
- const backOff = (state, at) => {
61
+ const backOff = (state, at) => Effect.gen(function* () {
57
62
  state.failures += 1;
58
- state.floor = at + Math.min(state.max, state.initial * 2 ** (state.failures - 1));
59
- };
60
- 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* () {
61
68
  state.failures = 0;
62
69
  state.floor = 0;
63
- };
70
+ state.exhausted = false;
71
+ state.step = yield* Schedule.toStep(state.policy);
72
+ });
64
73
  const status = Effect.forEach(states, (state) => state.wakeup.next.pipe(Effect.map((next) => ({
65
74
  name: state.wakeup.name,
66
75
  next,
67
76
  failures: state.failures,
68
77
  retryAt: state.floor > 0 ? Option.some(state.floor) : Option.none()
69
78
  }))));
70
- const deadlineOf = (state, now) => state.wakeup.next.pipe(Effect.map(Option.map((next) => Math.max(next, state.floor))), Effect.catchCause((cause) => {
71
- if (state.floor <= now) backOff(state, now);
72
- return Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause).pipe(Effect.as(Option.some(state.floor)));
79
+ 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) => {
80
+ return Effect.gen(function* () {
81
+ if (state.floor <= now) yield* backOff(state, now);
82
+ yield* Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause);
83
+ return state.exhausted ? Option.none() : Option.some(state.floor);
84
+ });
73
85
  }));
74
86
  const reconcileWith = (force) => reconcilePermit.withPermit(Effect.gen(function* () {
75
87
  const now = yield* Clock.currentTimeMillis;
@@ -96,22 +108,21 @@ const makeWakeups = (context, definitions = []) => Effect.gen(function* () {
96
108
  const now = yield* Clock.currentTimeMillis;
97
109
  yield* slot.arm(now + 3e4);
98
110
  for (const state of states) yield* Effect.gen(function* () {
99
- const at = yield* Clock.currentTimeMillis;
100
- if (state.floor > at) return;
111
+ if (state.exhausted || state.floor > (yield* Clock.currentTimeMillis)) return;
101
112
  const next = yield* state.wakeup.next;
113
+ const at = yield* Clock.currentTimeMillis;
102
114
  if (Option.isNone(next) || next.value > at) return;
103
115
  const exit = yield* Effect.exit(state.wakeup.run);
104
116
  if (Exit.isFailure(exit)) {
105
- backOff(state, yield* Clock.currentTimeMillis);
117
+ yield* backOff(state, yield* Clock.currentTimeMillis);
106
118
  yield* Effect.logError(`Wakeup ${state.wakeup.name} failed`, exit.cause);
107
119
  return;
108
120
  }
109
121
  const after = yield* state.wakeup.next;
110
- if (!reportsMore(exit.value) && Option.isSome(after) && after.value <= at) backOff(state, yield* Clock.currentTimeMillis);
111
- else recover(state);
122
+ if (!reportsMore(exit.value) && Option.isSome(after) && after.value <= at) yield* backOff(state, yield* Clock.currentTimeMillis);
123
+ else yield* recover(state);
112
124
  }).pipe(Effect.catchCause((cause) => Clock.currentTimeMillis.pipe(Effect.flatMap((at) => {
113
- backOff(state, at);
114
- return Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause);
125
+ return backOff(state, at).pipe(Effect.andThen(Effect.logError(`Wakeup ${state.wakeup.name} deadline failed`, cause)));
115
126
  }))));
116
127
  }).pipe(Effect.ensuring(reconcileWith(true).pipe(Effect.catchCause((cause) => Effect.logError("Wakeup reconciliation failed", cause)))), Effect.catchCause((cause) => Effect.logError("Wakeup alarm failed", cause))));
117
128
  const complete = provided();
@@ -121,5 +132,4 @@ const makeWakeups = (context, definitions = []) => Effect.gen(function* () {
121
132
  alarm: alarm.pipe(Effect.provide(complete))
122
133
  };
123
134
  });
124
- //#endregion
125
135
  export { alarmSlotLayer as a, makeWakeups as i, Wakeup as n, Wakeups as r, RequestTouch as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamsy/serve",
3
- "version": "0.4.0",
3
+ "version": "0.6.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.4.0"
90
+ "@streamsy/core": "^0.6.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.4.0",
101
+ "@streamsy/storage": "0.6.0",
102
102
  "@types/bun": "^1.4.3",
103
103
  "alchemy": "2.0.0-beta.83",
104
104
  "effect": "4.0.5",
@@ -1,60 +0,0 @@
1
- import { r as Wakeups } from "./wakeup-BtnMEjLv.js";
2
- import { Context, Effect, Layer, Option, Scope } from "effect";
3
- import { HttpRouter, HttpServerRespondable, HttpServerResponse } from "effect/http";
4
- import { Storage, StorageFault } from "@streamsy/core";
5
- //#region src/cloudflare/object-runtime.d.ts
6
- type ObjectServices<R = never> = Storage | Wakeups | R;
7
- type ObjectApp<R = never> = Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", ObjectServices<R>> | HttpRouter.Request.From<"Error", HttpServerRespondable.Respondable>>;
8
- //#endregion
9
- //#region src/cloudflare/placement.d.ts
10
- interface Placement {
11
- readonly name: (streamPath: string) => string;
12
- }
13
- /** The parse side of a stream, which is all a placement rule reads. */
14
- interface Parser<Params> {
15
- parse(id: string): Option.Option<Params>;
16
- }
17
- /**
18
- * One rule in a stream placement: a stream and the object name its
19
- * addresses belong to.
20
- *
21
- * The owner takes the same parameters its own stream decodes, so the object name
22
- * and the storage routing agree by construction. `owner` uses method syntax on
23
- * purpose: it gives the parameter its contextually typed shape at the call site
24
- * and keeps the rule assignable to the erased table.
25
- */
26
- interface OwnerRule<Params> {
27
- readonly stream: Parser<Params>;
28
- /** Names the object that owns every stream matching `stream`. */
29
- owner(params: Params): string;
30
- }
31
- /**
32
- * A rule after its parameter type is erased. `byRoute` reads each rule only
33
- * through that rule's own `parse`, so the erased parameter is never observed.
34
- */
35
- interface ErasedOwnerRule {
36
- readonly stream: Parser<unknown>;
37
- owner(params: never): string;
38
- }
39
- /**
40
- * Declare one placement rule with its stream's parameters in scope.
41
- *
42
- * The helper exists so the owner parameter is typed and checked. A rule passed
43
- * to `Placement.byRoute` without it would see the erased parameter set instead.
44
- */
45
- declare const rule: <Params>(spec: OwnerRule<Params>) => ErasedOwnerRule;
46
- declare const Placement: {
47
- readonly byStream: () => Placement;
48
- readonly byKey: (key: (streamPath: string) => string) => Placement;
49
- /**
50
- * The first matching rule names the object.
51
- *
52
- * A path that no rule matches yields `""`, which the router turns into
53
- * `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
54
- * stream cannot decode a segment does not own that path, so the table falls
55
- * through to the next rule.
56
- */
57
- readonly byRoute: (rules: ReadonlyArray<ErasedOwnerRule>) => Placement;
58
- };
59
- //#endregion
60
- export { ObjectServices as i, rule as n, ObjectApp as r, Placement as t };
@@ -1,130 +0,0 @@
1
- import { i as makeWakeups, r as Wakeups, t as RequestTouch } from "./wakeup-CWKg8pts.js";
2
- import { Context, Effect, Exit, Layer, Option, Scope } from "effect";
3
- import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
4
- import { Http } from "@streamsy/core";
5
- import { securityHeaders, streamPath } from "@streamsy/core/http";
6
- //#region src/cloudflare/host-program.ts
7
- /**
8
- * Reconciliation belongs to the mutation's finalizer. A request interruption
9
- * can be pending when the mutation's own uninterruptible region ends, so a
10
- * second sequential uninterruptible effect is not sufficient. A failed
11
- * reconciliation is logged and does not replace the mutation's result.
12
- */
13
- const withMutationReconciliation = (effect, reconcile) => effect.pipe(Effect.ensuring(reconcile.pipe(Effect.uninterruptible, Effect.catchCause((cause) => Effect.logError("Wakeup reconciliation failed", cause)))));
14
- //#endregion
15
- //#region src/cloudflare/object-runtime.ts
16
- /** Both object hosts install this middleware, including around user routes. */
17
- function objectApp(app, context) {
18
- const middleware = HttpRouter.middleware((handler) => Effect.gen(function* () {
19
- const request = yield* HttpServerRequest.HttpServerRequest;
20
- const marker = { touched: false };
21
- const wakeups = Context.get(context, Wakeups);
22
- return yield* withMutationReconciliation(handler, Effect.suspend(() => [
23
- "PUT",
24
- "POST",
25
- "DELETE"
26
- ].includes(request.method) || marker.touched ? wakeups.reconcile : Effect.void)).pipe(Effect.catchDefect(() => Effect.succeed(HttpServerResponse.text("Internal server error", {
27
- status: 500,
28
- headers: Http.securityHeaders
29
- }))), Effect.provideService(RequestTouch, marker));
30
- }).pipe(Effect.provide(context)), { global: true });
31
- return Layer.merge(app, middleware);
32
- }
33
- const unavailable = () => HttpServerResponse.text("Storage unavailable", {
34
- status: 503,
35
- headers: {
36
- ...Http.securityHeaders,
37
- "retry-after": "1"
38
- }
39
- });
40
- /** One lazy successful acquisition per object owner. Failed attempts close before retry. */
41
- function acquireObject(layer, compile, wakeups) {
42
- return Effect.gen(function* () {
43
- const owner = yield* Scope.Scope;
44
- return yield* Effect.gen(function* () {
45
- const attempt = yield* Scope.fork(owner);
46
- return yield* Effect.gen(function* () {
47
- const context = yield* Layer.buildWithScope(layer, attempt);
48
- const scheduler = yield* makeWakeups(context, wakeups);
49
- return yield* compile(scheduler.context, scheduler.alarm).pipe(Effect.provideService(Scope.Scope, attempt));
50
- }).pipe(Effect.onExit((exit) => Exit.isFailure(exit) ? Scope.close(attempt, exit) : Effect.void));
51
- }).pipe(Effect.cachedWithTTL((exit) => Exit.isSuccess(exit) ? "Infinity" : 0));
52
- });
53
- }
54
- /** Request markers are all supplied by this exact object context. */
55
- function providedApp(app, context) {
56
- return objectApp(app, context).pipe(HttpRouter.provideRequest(Layer.succeedContext(context)));
57
- }
58
- //#endregion
59
- //#region src/cloudflare/placement.ts
60
- /**
61
- * Declare one placement rule with its stream's parameters in scope.
62
- *
63
- * The helper exists so the owner parameter is typed and checked. A rule passed
64
- * to `Placement.byRoute` without it would see the erased parameter set instead.
65
- */
66
- const rule = (spec) => spec;
67
- const Placement = {
68
- byStream: () => ({ name: (streamPath) => streamPath }),
69
- byKey: (key) => ({ name: key }),
70
- /**
71
- * The first matching rule names the object.
72
- *
73
- * A path that no rule matches yields `""`, which the router turns into
74
- * `400 Invalid placement key`. An owner that throws is a `500`. A rule whose
75
- * stream cannot decode a segment does not own that path, so the table falls
76
- * through to the next rule.
77
- */
78
- byRoute: (rules) => ({ name: (streamPath) => {
79
- for (const entry of rules) {
80
- const params = entry.stream.parse(streamPath);
81
- if (Option.isNone(params)) continue;
82
- return entry.owner(params.value);
83
- }
84
- return "";
85
- } })
86
- };
87
- //#endregion
88
- //#region src/cloudflare/router.ts
89
- const badRequest = (message) => new Response(message, {
90
- status: 400,
91
- headers: securityHeaders
92
- });
93
- const internalError = () => new Response("Internal server error", {
94
- status: 500,
95
- headers: securityHeaders
96
- });
97
- const invalidPlacement = () => badRequest("Invalid placement key");
98
- const resolvePlacement = (placement, streamPath) => {
99
- try {
100
- const candidate = placement.name(streamPath);
101
- const name = String(candidate);
102
- return candidate === name && name.length > 0 ? {
103
- ok: true,
104
- name
105
- } : {
106
- ok: false,
107
- response: invalidPlacement()
108
- };
109
- } catch {
110
- return {
111
- ok: false,
112
- response: internalError()
113
- };
114
- }
115
- };
116
- const router = (options) => {
117
- const path = streamPath(options.prefix);
118
- const placement = options.placement ?? Placement.byStream();
119
- return { fetch(request, env) {
120
- const url = new URL(request.url);
121
- const streamPath = path.strip(url.pathname);
122
- if (!streamPath || streamPath === url.pathname) return badRequest(`Stream path required: ${path.requiredPathPattern()}`);
123
- const child = resolvePlacement(placement, streamPath);
124
- if (!child.ok) return child.response;
125
- const namespace = options.namespace(env);
126
- return namespace.get(namespace.idFromName(child.name)).fetch(request);
127
- } };
128
- };
129
- //#endregion
130
- export { acquireObject as a, rule as i, router as n, providedApp as o, Placement as r, unavailable as s, resolvePlacement as t };