@requence/service 0.8.2-edge.1159 → 0.8.2-edge.1160
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 +47 -729
- package/build/index.js +255 -67
- package/build/index.js.map +11 -6
- package/build/types/helpers/src/protocol/command.d.ts +36 -0
- package/build/types/helpers/src/protocol/command.d.ts.map +1 -1
- package/build/types/helpers/src/protocol/taskOptions.d.ts +3 -0
- package/build/types/helpers/src/protocol/taskOptions.d.ts.map +1 -1
- package/build/types/service/src/createAmqpConnection.d.ts +0 -1
- package/build/types/service/src/createAmqpConnection.d.ts.map +1 -1
- package/build/types/service/src/helpers.d.ts +20 -1
- package/build/types/service/src/helpers.d.ts.map +1 -1
- package/build/types/service/src/index.d.ts +2 -0
- package/build/types/service/src/index.d.ts.map +1 -1
- package/build/types/service/src/store.d.ts +104 -0
- package/build/types/service/src/store.d.ts.map +1 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# @requence/service
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connects a TypeScript / Bun service to the Requence operator: it receives messages, runs your
|
|
4
|
+
handler, and delivers the result back.
|
|
5
|
+
|
|
6
|
+
> **This README is a getting-started guide.** The reference — every context method, the store,
|
|
7
|
+
> callbacks, surfaces, deferred delivery, the CLI — lives at
|
|
8
|
+
> **<https://docs.requence.cloud>**.
|
|
4
9
|
|
|
5
10
|
## Installation
|
|
6
11
|
|
|
@@ -10,7 +15,8 @@ npm install @requence/service
|
|
|
10
15
|
|
|
11
16
|
## Authentication
|
|
12
17
|
|
|
13
|
-
Every service needs an **access token** to connect. Copy it from the **Services** list view in
|
|
18
|
+
Every service needs an **access token** to connect. Copy it from the **Services** list view in
|
|
19
|
+
the Requence UI by clicking **Copy credentials**.
|
|
14
20
|
|
|
15
21
|
The token is resolved in this order:
|
|
16
22
|
|
|
@@ -22,745 +28,57 @@ The token is resolved in this order:
|
|
|
22
28
|
REQUENCE_SERVICE_ACCESS_TOKEN=your-token bun run index.ts
|
|
23
29
|
```
|
|
24
30
|
|
|
25
|
-
##
|
|
26
|
-
|
|
27
|
-
```typescript
|
|
28
|
-
import { createService } from '@requence/service'
|
|
31
|
+
## Your first service
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
})
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
The second argument to `createService` is the **version** of the service definition you are implementing. When Requence dispatches a message, the handler runs and the return value is sent back as the result.
|
|
36
|
-
|
|
37
|
-
### Options object
|
|
38
|
-
|
|
39
|
-
Instead of a bare version string, you can pass an options object:
|
|
33
|
+
Create the service in the Requence UI first — its input schema, configuration schema and
|
|
34
|
+
outputs. Then implement the version:
|
|
40
35
|
|
|
41
36
|
```typescript
|
|
42
37
|
import { createService } from '@requence/service'
|
|
43
38
|
|
|
44
|
-
createService(
|
|
45
|
-
{
|
|
46
|
-
version: '1.0.0',
|
|
47
|
-
prefetch: 5, // process up to 5 messages in parallel (default: 1)
|
|
48
|
-
connectionTimeout: 30_000, // ms to wait before throwing (default: 3000)
|
|
49
|
-
silent: false, // suppress lifecycle logs
|
|
50
|
-
},
|
|
51
|
-
async (ctx) => {
|
|
52
|
-
return processData(ctx.input)
|
|
53
|
-
},
|
|
54
|
-
)
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
### TLS connection options via environment
|
|
58
|
-
|
|
59
|
-
For `amqps://` connections, TLS settings can be overridden with environment
|
|
60
|
-
variables — useful for supplying a private CA or relaxing verification in a
|
|
61
|
-
deployment without changing code:
|
|
62
|
-
|
|
63
|
-
| Variable | Effect |
|
|
64
|
-
| --------------------- | --------------------------------------------------------------------- |
|
|
65
|
-
| `CA` | Inline PEM certificate(s) to trust as the CA. |
|
|
66
|
-
| `CA_FILE` | Path to a PEM file to trust as the CA (`CA` wins when both are set). |
|
|
67
|
-
| `REJECT_UNAUTHORIZED` | Set to `false`/`0`/`no` to disable certificate verification. |
|
|
68
|
-
|
|
69
|
-
These are a **fallback**: any matching value passed in code via
|
|
70
|
-
`connectionOptions` (`ca`, `rejectUnauthorized`) takes precedence.
|
|
71
|
-
|
|
72
|
-
### Return value
|
|
73
|
-
|
|
74
|
-
`createService` returns a `ServiceApi` object immediately (the connection is established in the background):
|
|
75
|
-
|
|
76
|
-
```typescript
|
|
77
|
-
const service = createService('1.0.0', handler)
|
|
78
|
-
|
|
79
|
-
// wait for the connection to be ready
|
|
80
|
-
await service.open()
|
|
81
|
-
|
|
82
|
-
// gracefully close the connection
|
|
83
|
-
await service.close()
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
## Context API
|
|
87
|
-
|
|
88
|
-
Every handler receives a `ctx` object.
|
|
89
|
-
|
|
90
|
-
### Data access
|
|
91
|
-
|
|
92
|
-
| Property | Description |
|
|
93
|
-
|---|---|
|
|
94
|
-
| `ctx.input` | The input data routed to this service node from the task template |
|
|
95
|
-
| `ctx.configuration` | The static configuration set on the service node in the UI |
|
|
96
|
-
| `ctx.taskId` | The unique ID of the current task execution |
|
|
97
|
-
|
|
98
|
-
### Logging
|
|
99
|
-
|
|
100
|
-
`ctx.debug` sends log messages to the Requence UI in real time:
|
|
101
|
-
|
|
102
|
-
```typescript
|
|
103
|
-
ctx.debug.log('Processing started')
|
|
104
|
-
ctx.debug.info('Step complete', { step: 1 })
|
|
105
|
-
ctx.debug.warn('Something looks off')
|
|
106
|
-
ctx.debug.error('An error occurred', error)
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
### Flow control
|
|
110
|
-
|
|
111
|
-
#### `ctx.retry(delay?)`
|
|
112
|
-
|
|
113
|
-
Instructs Requence to retry this service after an optional delay in milliseconds (minimum 100 ms). No code executes after this call.
|
|
114
|
-
|
|
115
|
-
```typescript
|
|
116
|
-
createService('1.0.0', async (ctx) => {
|
|
117
|
-
const db = await getDbConnection()
|
|
118
|
-
|
|
119
|
-
if (!db.isConnected) {
|
|
120
|
-
ctx.retry(2_000) // retry in 2 seconds
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return db.query('SELECT ...')
|
|
124
|
-
})
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
> **Note:** It is your responsibility to prevent infinite retry loops.
|
|
128
|
-
|
|
129
|
-
#### `ctx.abort(reason?)`
|
|
130
|
-
|
|
131
|
-
Instructs Requence to abort this service immediately. If the service node's **on fail** output is not connected, the entire task fails.
|
|
132
|
-
|
|
133
|
-
```typescript
|
|
134
|
-
createService('1.0.0', (ctx) => {
|
|
135
|
-
if (!ctx.input.requiredField) {
|
|
136
|
-
ctx.abort('Missing required field')
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return processData(ctx.input)
|
|
140
|
-
})
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
#### `ctx.skip()`
|
|
144
|
-
|
|
145
|
-
Puts the message back on the queue without processing it. The next available service instance will receive it instead.
|
|
146
|
-
|
|
147
|
-
#### `ctx.toOutput(name, value)`
|
|
148
|
-
|
|
149
|
-
Routes the result to a specific **named output** on the service node. Use this when your service definition has multiple outputs:
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
createService('1.0.0', (ctx) => {
|
|
153
|
-
if (ctx.input.type === 'pdf') {
|
|
154
|
-
return ctx.toOutput('pdf', { url: '...' })
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return ctx.toOutput('other', { raw: ctx.input })
|
|
158
|
-
})
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
#### `ctx.defer(reason?)`
|
|
162
|
-
|
|
163
|
-
Marks the message as **deferred**. The service acknowledges the message but signals that the result will be delivered later via `service.act()`. Returns a **message key** that you must store and pass to `act()`:
|
|
164
|
-
|
|
165
|
-
```typescript
|
|
166
39
|
createService('1.0.0', (ctx) => {
|
|
167
|
-
|
|
168
|
-
externalQueue.push({ messageKey, payload: ctx.input })
|
|
169
|
-
// handler returns — message is held open until act() is called
|
|
170
|
-
})
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
#### `ctx.terminated`
|
|
174
|
-
|
|
175
|
-
A `Promise` that resolves when the task is stopped (cancelled via the UI or API, or terminated by another node). Use it in continuous/generator services to know when to stop producing values.
|
|
176
|
-
|
|
177
|
-
`ctx.terminated.signal` is a native [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that fires at the same moment — pass it to any abort-aware API:
|
|
178
|
-
|
|
179
|
-
```typescript
|
|
180
|
-
import { setTimeout } from 'node:timers/promises'
|
|
181
|
-
import { createService } from '@requence/service'
|
|
182
|
-
|
|
183
|
-
// Poll every 5 seconds, exit immediately when the task is stopped
|
|
184
|
-
createService('1.0.0', async function* (ctx) {
|
|
185
|
-
while (true) {
|
|
186
|
-
await setTimeout(5_000, null, { signal: ctx.terminated.signal })
|
|
187
|
-
yield await pollForUpdates()
|
|
188
|
-
}
|
|
189
|
-
})
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Or race against it directly:
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
const result = await Promise.race([
|
|
196
|
-
fetchData(),
|
|
197
|
-
ctx.terminated,
|
|
198
|
-
])
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
> The framework automatically catches the `AbortError` thrown by abort-aware APIs when the task is terminated — no `try/catch` needed inside a generator.
|
|
202
|
-
|
|
203
|
-
## Continuous (Generator) Mode
|
|
204
|
-
|
|
205
|
-
When a service node is configured in **continuous mode**, the handler can be an async generator. Each `yield` sends an incremental result to Requence; the final `return` (or generator completion) signals the end of processing:
|
|
206
|
-
|
|
207
|
-
```typescript
|
|
208
|
-
createService('1.0.0', async function* (ctx) {
|
|
209
|
-
for (const chunk of await fetchChunks(ctx.input)) {
|
|
210
|
-
yield { chunk }
|
|
211
|
-
}
|
|
212
|
-
})
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
The generator is terminated automatically when `ctx.terminated` resolves. Yielded values are still sent before the generator exits.
|
|
216
|
-
|
|
217
|
-
## Deferred Delivery via `service.act()`
|
|
218
|
-
|
|
219
|
-
After deferring a message with `ctx.defer()`, use `service.act()` to deliver the result later — even from a different process run:
|
|
220
|
-
|
|
221
|
-
```typescript
|
|
222
|
-
const service = createService('1.0.0', (ctx) => {
|
|
223
|
-
const messageKey = ctx.defer()
|
|
224
|
-
saveMessageKey(ctx.taskId, messageKey)
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
// ... in a webhook handler or background job:
|
|
228
|
-
await service.act(messageKey, async (api) => {
|
|
229
|
-
api.send({ result: 'done' })
|
|
230
|
-
// or: api.sendToOutput('success', { result: 'done' })
|
|
231
|
-
// or: api.abort('something went wrong')
|
|
232
|
-
})
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
`act()` also supports returning a value or an iterable directly from the actor function, which behaves the same as calling `api.send()` for each value.
|
|
236
|
-
|
|
237
|
-
### One instance per defer key — your lock, not ours
|
|
238
|
-
|
|
239
|
-
> **You own the exclusivity.** You own the pending-key storage, so you own the
|
|
240
|
-
> lock. A single connection refuses a key it is already acting on, but that check
|
|
241
|
-
> is per-connection and in memory: **two replicas that both load the pending keys
|
|
242
|
-
> on boot will both `act()` the same key**, both register callbacks, and both
|
|
243
|
-
> render — and whoever rendered last takes the surface over.
|
|
244
|
-
|
|
245
|
-
Requence does not arbitrate this: enforcing it would need cross-replica holder
|
|
246
|
-
state plus a takeover path, so a dead holder could not block a key forever.
|
|
247
|
-
Instead the symptom is made loud — when a render replaces a surface that is still
|
|
248
|
-
live on another connection, the server logs a `surface takeover` error naming both
|
|
249
|
-
connections.
|
|
250
|
-
|
|
251
|
-
Take a lease on the key in your own store before acting (`SELECT … FOR UPDATE SKIP
|
|
252
|
-
LOCKED`, a Redis `SET NX`, one designated attacher), and only act on keys you hold.
|
|
253
|
-
|
|
254
|
-
`act()` is also the live window of a deferred node, so it can draw and redraw the
|
|
255
|
-
node's [surface](#surfaces--a-ui-of-your-own-on-the-node) — see
|
|
256
|
-
[`api.render`](#apirenderui-props-options--rendering-from-act).
|
|
257
|
-
|
|
258
|
-
## Callbacks — functions you emit alongside your data
|
|
259
|
-
|
|
260
|
-
A function in what a service emits arrives downstream as a function the graph may
|
|
261
|
-
call back. An email receiver yields a mail *and* a `delete()` a later node may or
|
|
262
|
-
may not use.
|
|
263
|
-
|
|
264
|
-
This section is the reference for callback behaviour in every SDK — the Python SDK
|
|
265
|
-
(`python/README.md`) speaks the same wire and states only where it differs. The
|
|
266
|
-
design authority is
|
|
267
|
-
[`knowledge/durable-callbacks.md`](../../knowledge/durable-callbacks.md).
|
|
268
|
-
|
|
269
|
-
### Where you write the function decides how long it lives
|
|
270
|
-
|
|
271
|
-
There are two kinds, and you pick by **where you write it** — the same rule as
|
|
272
|
-
[handler props](#handler-props--where-you-write-the-function-decides-how-long-it-can-be-pressed),
|
|
273
|
-
stated for a second mechanism:
|
|
274
|
-
|
|
275
|
-
```typescript
|
|
276
|
-
import { createDurableCallback, createService } from '@requence/service'
|
|
277
|
-
|
|
278
|
-
// DURABLE. Declared ONCE at module scope, addressed by the name it is given. It
|
|
279
|
-
// closes over nothing per mail, so any instance of this version can run it —
|
|
280
|
-
// this one, or the one that replaces it in a redeploy.
|
|
281
|
-
const deleteMail = createDurableCallback<null, { uid: number }, 'receiver'>(
|
|
282
|
-
'deleteMail',
|
|
283
|
-
async (_arg, { uid, configuration }) => {
|
|
284
|
-
const client = await connect(configuration) // rebuilt, never captured
|
|
285
|
-
await client.messageDelete([uid], { uid: true })
|
|
286
|
-
client.close()
|
|
287
|
-
},
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
createService('receiver', async function* () {
|
|
291
|
-
for await (const mail of mails) {
|
|
292
|
-
yield {
|
|
293
|
-
subject: mail.subject,
|
|
294
|
-
// A reference, not a closure: one registration, however many mails.
|
|
295
|
-
delete: deleteMail.for({ uid: mail.uid }),
|
|
296
|
-
// CONNECTION-BOUND. Needs the LIVE imap client, so it correctly dies with
|
|
297
|
-
// this message — and holds everything it captured until then.
|
|
298
|
-
stream: () => imap.download(mail.uid),
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
})
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
**Write it inline when it genuinely needs the live process** — an open stream, a
|
|
305
|
-
client the handler is mid-conversation with. **Declare it with
|
|
306
|
-
`createDurableCallback` when it needs only data**, and bind that data with
|
|
307
|
-
`.for(…)`.
|
|
308
|
-
|
|
309
|
-
A connection-bound function is kept in the connection's registry until the message
|
|
310
|
-
**settles**, which for a CONTINUOUS handler is until the task *ends*. So a generator
|
|
311
|
-
minting one per emission grows by one closure per emission for the life of the run,
|
|
312
|
-
and each closure holds on to everything that was in scope when it was written — the
|
|
313
|
-
whole mail, not just the uid. It is also why a connection-bound callback is only
|
|
314
|
-
useful from a generator handler: a LINEAR handler's registrations go the moment it
|
|
315
|
-
returns, so the reference it emitted is dead before anything downstream sees it. A
|
|
316
|
-
durable one registers nothing, so a LINEAR node can emit one and a later node can
|
|
317
|
-
still call it.
|
|
318
|
-
|
|
319
|
-
### What a durable callback is handed
|
|
320
|
-
|
|
321
|
-
```typescript
|
|
322
|
-
createDurableCallback<Arg, Bound, Configuration>(name, (arg, context) => …)
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
| | |
|
|
326
|
-
|---|---|
|
|
327
|
-
| `arg` | what the graph passed when it called the callback |
|
|
328
|
-
| `context.…` | whatever `.for({ … })` bound to this emission, spread |
|
|
329
|
-
| `context.configuration` | the node's **static** configuration |
|
|
330
|
-
|
|
331
|
-
**And that is all of it.** No message, no task, no connection — that is exactly what
|
|
332
|
-
makes it durable: it may run in an instance that never saw the emission, started
|
|
333
|
-
after the emitting process died. So anything it needs it **rebuilds** rather than
|
|
334
|
-
captures, and `configuration` is what it rebuilds from.
|
|
335
|
-
|
|
336
|
-
The three type arguments are the three things the two sides disagree about, and
|
|
337
|
-
`Configuration` takes either a type or the **name of the service version** to look it
|
|
338
|
-
up from (`ConfigurationOf<'receiver'>`). **Prefer the version-name form**: that type
|
|
339
|
-
is generated into `requence-env.d.ts` beside the version it belongs to, so naming the
|
|
340
|
-
version is the only form that cannot drift from what the node actually sends.
|
|
341
|
-
|
|
342
|
-
A binding may not be:
|
|
343
|
-
|
|
344
|
-
- **a scalar.** The bound data *is* the context and it is spread, so a string would
|
|
345
|
-
quietly become `{ 0: 'd', 1: 'e', … }`.
|
|
346
|
-
- **a `configuration` key.** The node's own configuration has that name, and a
|
|
347
|
-
precedence rule either way would mean your data or the node's credentials silently
|
|
348
|
-
disappearing.
|
|
349
|
-
|
|
350
|
-
Both are refused where you wrote them, and again when an invocation is unpacked.
|
|
351
|
-
|
|
352
|
-
A declaration with nothing to bind is emitted as itself — `.for()` is optional there,
|
|
353
|
-
and TypeScript only lets you leave it off when there is genuinely nothing to bind:
|
|
354
|
-
|
|
355
|
-
```typescript
|
|
356
|
-
const acknowledge = createDurableCallback<null>('acknowledge', () => ack())
|
|
357
|
-
|
|
358
|
-
yield { subject: mail.subject, acknowledge }
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
**A callback that takes no argument is `RequenceCallback<null>`, not `<void>`** —
|
|
362
|
-
both here and in the output schema the position is declared in. `void` has no root
|
|
363
|
-
type a JSON schema can hold, so the schema cannot be authored at all, and
|
|
364
|
-
`undefined` compiles but silently drops the `callback` marker along with the
|
|
365
|
-
argument, leaving a plain object where the reference has to survive validation. A
|
|
366
|
-
graph then calls it as `context.input.delete(null)`.
|
|
367
|
-
|
|
368
|
-
**Two declarations under the same name throw at registration**, for the reason a
|
|
369
|
-
duplicate `createUI` id does: the name is the whole address, so an ambiguous one is an
|
|
370
|
-
invocation reaching the *wrong* handler — which reads like a bug in the handler — and
|
|
371
|
-
that is worse than a service that refuses to start.
|
|
372
|
-
|
|
373
|
-
### An invocation waits out a redeploy, not a weekend
|
|
374
|
-
|
|
375
|
-
Consuming the invocations is automatic. The queue is
|
|
376
|
-
`service-<name>@<version>-callbacks`, one per version, created by Requence when the
|
|
377
|
-
version is provisioned and asserted again by the SDK on connect. Whichever instance is
|
|
378
|
-
free serves it, so a rolling restart is invisible to the graph.
|
|
379
|
-
|
|
380
|
-
It is **not a mailbox**: an invocation expires after ~60s, and an expired or
|
|
381
|
-
unroutable one is reported on the run — a system note on the node that emitted the
|
|
382
|
-
callback, saying whether the version has nothing running or is no longer deployed at
|
|
383
|
-
all. A callback invoked today and run whenever somebody next deploys would be a silent
|
|
384
|
-
effect, and nothing checks that a service is up before a graph calls
|
|
385
|
-
`context.input.delete()`.
|
|
386
|
-
|
|
387
|
-
A name this build does not declare, and a handler that throws, are said out loud in
|
|
388
|
-
this process's log — the graph called the callback and moved on, so there is no
|
|
389
|
-
request left to fail. The invocation is **acked either way**: a requeue would rerun a
|
|
390
|
-
side-effecting callback forever.
|
|
391
|
-
|
|
392
|
-
### The pile-up warning
|
|
393
|
-
|
|
394
|
-
If connection-bound callbacks accumulate, the SDK tells you in its **own log** — once
|
|
395
|
-
when the registry crosses a threshold, then at each power of ten — naming the `yield`
|
|
396
|
-
positions doing it and the fix:
|
|
397
|
-
|
|
398
|
-
| Where | First warning | Then |
|
|
399
|
-
|---|---|---|
|
|
400
|
-
| a dev session (dev overlay) | 100 | ×10 |
|
|
401
|
-
| a deployed version | 1 000 | ×10 (10k, 100k, …) |
|
|
402
|
-
|
|
403
|
-
A dev session warns ten times earlier on purpose: a hundred emissions in `bun run dev`
|
|
404
|
-
already means a generator is minting closures in a loop, and catching it there is worth
|
|
405
|
-
far more than catching it in production. Two counters feed it — this delivery's, which
|
|
406
|
-
identifies the one generator, and the whole connection's, which is what actually
|
|
407
|
-
threatens the process when many tasks each stay under their own threshold.
|
|
408
|
-
|
|
409
|
-
It is a **log line for you**, not a task event: you are the only person who can act on
|
|
410
|
-
it. Nothing is capped and nothing is evicted — a cap would kill a legitimate long-lived
|
|
411
|
-
handle at an arbitrary number you cannot exempt it from.
|
|
412
|
-
|
|
413
|
-
### Two gaps
|
|
414
|
-
|
|
415
|
-
- **Computed configuration is not available to a durable callback**, and cannot be:
|
|
416
|
-
config functions are evaluated per dispatch, and this call belongs to none. A node
|
|
417
|
-
whose credentials are computed cannot serve one.
|
|
418
|
-
- **A logic node's `createCallback` global is connection-bound only.** There is no
|
|
419
|
-
durable form inside a script.
|
|
420
|
-
|
|
421
|
-
## Surfaces — a UI of your own on the node
|
|
422
|
-
|
|
423
|
-
A service can ship its own UI component and have Requence render it **on the
|
|
424
|
-
publishing node** in the task canvas, inside a sandboxed iframe. There is no UI
|
|
425
|
-
protocol: the contract is **props**, and a function inside them arrives in the
|
|
426
|
-
component as an invokable function.
|
|
427
|
-
|
|
428
|
-
This section is the reference for surface behaviour in every SDK — the Python SDK
|
|
429
|
-
(`python/README.md`) publishes the same render payload and answers the same
|
|
430
|
-
invocation. The design authority is
|
|
431
|
-
[`knowledge/service-ui-surfaces.md`](../../knowledge/service-ui-surfaces.md).
|
|
432
|
-
|
|
433
|
-
### `createUI(url, defaults?)` — where a component comes from
|
|
434
|
-
|
|
435
|
-
```typescript
|
|
436
|
-
const ui = createUI<{ title: string; at: string; dense: boolean }>(
|
|
437
|
-
// A URL, so it resolves against THIS module — not against whatever directory
|
|
438
|
-
// the service was started from. `{ url, id }` instead overrides the stable id,
|
|
439
|
-
// which defaults to the built file's name.
|
|
440
|
-
new URL('./dist/panel.js', import.meta.url),
|
|
441
|
-
// Optional default props: what does not change from one message to the next.
|
|
442
|
-
{ dense: true },
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
// A render carries only what differs.
|
|
446
|
-
ctx.render(ui, { title: 'Processing', at: new Date().toISOString() })
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
**A render is a patch.** What the surface shows is the *fold* of three layers —
|
|
450
|
-
the UI's defaults, everything previously rendered onto that surface, and this
|
|
451
|
-
render — so redrawing one field takes one prop and leaves the rest standing:
|
|
452
|
-
|
|
453
|
-
```typescript
|
|
454
|
-
ctx.render(ui, { title: 'Processing', at: now() })
|
|
455
|
-
ctx.render(ui, { at: now() }) // still says "Processing"
|
|
456
|
-
ctx.render(ui, { title: 'Done' }) // and still carries the last `at`
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
The merge is **shallow**: a nested object is replaced wholesale, so changing part
|
|
460
|
-
of one means restating that subtree. Nothing is ever removed — an omitted prop
|
|
461
|
-
means "nothing to say about this", which is what makes a render a patch — so a
|
|
462
|
-
prop is taken back by saying something else about it (`{ error: null }`).
|
|
463
|
-
|
|
464
|
-
Defaults are the **weakest** layer: they are what a surface shows until some
|
|
465
|
-
render says otherwise, and a render that overrides one keeps it overridden for
|
|
466
|
-
every later render that stays silent about it. The flip side of all of this is
|
|
467
|
-
that every render's props are optional in TypeScript, so it can no longer catch a
|
|
468
|
-
*first* render that forgot half of them — completing the fold is yours.
|
|
469
|
-
|
|
470
|
-
The bundle is read and hashed once, at registration, and capped at 1 MiB — an
|
|
471
|
-
oversized component fails at startup rather than on its first render in
|
|
472
|
-
production. Requence never bundles for you: build the component with your own
|
|
473
|
-
toolchain into one ES module with a default `mount(element, ctx)` export.
|
|
474
|
-
|
|
475
|
-
**In React, you do not write `mount`.** Leave `react`, `react-dom/client`,
|
|
476
|
-
`react/jsx-runtime` and `@requence/surface-ui` external and the surface host
|
|
477
|
-
resolves them from its shared runtime — which also ships `createSurface`, so the
|
|
478
|
-
component is one default export and the surface state is its own props:
|
|
479
|
-
|
|
480
|
-
```tsx
|
|
481
|
-
import { Button, createSurface, Stack, Text } from '@requence/surface-ui'
|
|
482
|
-
|
|
483
|
-
import type { PropsOf } from '@requence/service'
|
|
484
|
-
import type { buttonUI } from './button.ts'
|
|
485
|
-
|
|
486
|
-
export default createSurface<PropsOf<typeof buttonUI>>(({ props, canInvoke }) => (
|
|
487
|
-
<Stack space={2}>
|
|
488
|
-
<Button disabled={!canInvoke} onClick={() => props.onClick()}>
|
|
489
|
-
{props.label}
|
|
490
|
-
</Button>
|
|
491
|
-
<Text variant="muted">{props.clicks} dispatched</Text>
|
|
492
|
-
</Stack>
|
|
493
|
-
))
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
No root, no `useState`, no `useEffect`, no teardown to forget. Anything nested
|
|
497
|
-
reads the same state with `useSurface<Props>()` instead of having `canInvoke`
|
|
498
|
-
drilled down to it. A bundle that ships its own React writes `mount` itself —
|
|
499
|
-
create a root, subscribe to `ctx.onUpdate`, unmount on teardown.
|
|
500
|
-
|
|
501
|
-
Every component also has a **stable id** — its built file's name, or whatever
|
|
502
|
-
`createUI({ url, id })` says. It survives rebuilds and restarts, which the content
|
|
503
|
-
hash deliberately does not, and it is what a durable handler is addressed by. Two
|
|
504
|
-
handles claiming one id is a startup error, so two `index.js`-shaped components —
|
|
505
|
-
or one file registered twice with different defaults — need explicit ids.
|
|
506
|
-
|
|
507
|
-
### Handler props — where you write the function decides how long it can be pressed
|
|
508
|
-
|
|
509
|
-
A function in a surface's props arrives in the component as an invokable function.
|
|
510
|
-
There are two kinds, and you pick by **where you write it**:
|
|
511
|
-
|
|
512
|
-
```typescript
|
|
513
|
-
const askUI = createUI<{ question: string; onSubmit(answer: string): void }>(
|
|
514
|
-
new URL('./dist/ask.js', import.meta.url),
|
|
515
|
-
{
|
|
516
|
-
// DURABLE. Module scope, so any instance of this version can run it — this
|
|
517
|
-
// one, or the one that replaces it in a redeploy. An invocation waits in the
|
|
518
|
-
// version's handler queue for ~60s, so the handler below is free to defer
|
|
519
|
-
// and exit, and a rolling restart is invisible to the viewer.
|
|
520
|
-
onSubmit(answer) {
|
|
521
|
-
void record(answer)
|
|
522
|
-
},
|
|
523
|
-
},
|
|
524
|
-
)
|
|
525
|
-
|
|
526
|
-
const service = createService('1.0.0', function* (ctx) {
|
|
527
|
-
ctx.render(askUI, {
|
|
528
|
-
question: 'ship it?',
|
|
529
|
-
// CONNECTION-BOUND. Closes over this running handler, so only this process
|
|
530
|
-
// can serve it and it dies when the handler returns — which is also why it
|
|
531
|
-
// needs a generator handler.
|
|
532
|
-
onSubmit: (answer) => ctx.debug.log(answer),
|
|
533
|
-
})
|
|
40
|
+
return { message: `Hello, ${ctx.input.name}!` }
|
|
534
41
|
})
|
|
535
42
|
```
|
|
536
43
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
prop path this process does not have is reported on stderr and dropped — the click
|
|
541
|
-
behind it was accepted long before, so there is no request left to fail.
|
|
542
|
-
|
|
543
|
-
Consuming the handler queue is automatic. It is `service-<name>@<version>-handlers`,
|
|
544
|
-
one per version, created by Requence when the version is provisioned and asserted
|
|
545
|
-
again by the SDK on connect.
|
|
546
|
-
|
|
547
|
-
**A durable handler still needs someone home.** The node can wait days; the
|
|
548
|
-
*click* cannot. While no instance of the version is running, every handler prop of
|
|
549
|
-
that version's surfaces reports `reachable: false` and the component withholds its
|
|
550
|
-
control — and an invocation that does get published expires in the queue after
|
|
551
|
-
~60s and is reported back to the viewer beside the surface, in Requence's own
|
|
552
|
-
error box, rather than running whenever somebody next deploys. So
|
|
553
|
-
the queue rides out a redeploy, not a weekend. Scale your version to zero and the
|
|
554
|
-
buttons it drew go quiet until you scale it back up, at which point they come back
|
|
555
|
-
on the open page with no reload.
|
|
556
|
-
|
|
557
|
-
### `api.render(ui, props, options?)` — rendering from `act()`
|
|
558
|
-
|
|
559
|
-
A deferring handler cannot register a *connection-bound* handler at all (they die
|
|
560
|
-
the moment it returns), so a surface whose buttons are written inline has to be
|
|
561
|
-
rendered from the [actor](#deferred-delivery-via-serviceact) — where they belong to
|
|
562
|
-
the `act()` call and live exactly as long as it does. (A handler written in the UI's
|
|
563
|
-
own defaults is durable and needs none of this.)
|
|
564
|
-
|
|
565
|
-
```typescript
|
|
566
|
-
const ui = createUI(new URL('./dist/button.js', import.meta.url))
|
|
567
|
-
|
|
568
|
-
const service = createService('1.0.0', (ctx) => {
|
|
569
|
-
pending.save(ctx.defer('waiting for a click'))
|
|
570
|
-
})
|
|
571
|
-
|
|
572
|
-
await service.open()
|
|
573
|
-
|
|
574
|
-
// On boot AND after each defer — this loop is what survives a restart.
|
|
575
|
-
for (const key of await pending.all()) {
|
|
576
|
-
void attach(key)
|
|
577
|
-
}
|
|
44
|
+
The second argument to `createService` is the **version** of the service definition you are
|
|
45
|
+
implementing. When Requence dispatches a message, the handler runs and the return value is sent
|
|
46
|
+
back as the result.
|
|
578
47
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
await service.act(key, async (api) => {
|
|
583
|
-
api.render(ui, {
|
|
584
|
-
onClick: createCallback<{ timestamp: number }>((arg) => clicks.push(arg)),
|
|
585
|
-
})
|
|
586
|
-
|
|
587
|
-
for await (const click of clicks) {
|
|
588
|
-
api.send(click) // completes the deferred node
|
|
589
|
-
break
|
|
590
|
-
}
|
|
591
|
-
})
|
|
592
|
-
|
|
593
|
-
await pending.remove(key)
|
|
594
|
-
}
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
Restart the process, reload the keys, `act()` again: the new render — fresh
|
|
598
|
-
connection, fresh callbacks — replaces the stale surface, and the browser needs to
|
|
599
|
-
know nothing about it. `props` is type-checked against `createUI<Props>` exactly as
|
|
600
|
-
it is for `ctx.render`.
|
|
601
|
-
|
|
602
|
-
### Three rules a component author does not get to skip
|
|
603
|
-
|
|
604
|
-
The design took these deliberately, and none of them is enforced for you.
|
|
605
|
-
|
|
606
|
-
**1. A handler's argument comes from the browser, and it is forgeable.** A viewer
|
|
607
|
-
with `task: UPDATE` can invoke any handler reference the stored props hold, with
|
|
608
|
-
anything under the byte cap — the component that was rendered is not what sends it.
|
|
609
|
-
Requence bounds *which* handler (only a reference some render actually published)
|
|
610
|
-
and *how big* the argument is, and deliberately nothing else: no schema declares an
|
|
611
|
-
author's props anywhere on the wire, and a handler is ordinary service code that can
|
|
612
|
-
check its own input. **Validate the argument in the handler**, exactly as you would
|
|
613
|
-
a webhook body — and keep anything the handler must not be *told* in its closure
|
|
614
|
-
rather than in the props. A durable handler reading `arg.deferKey` is fine (a key is
|
|
615
|
-
single-use and belongs to that node); the same handler reading `arg.amount` or
|
|
616
|
-
`arg.userId` is not.
|
|
617
|
-
|
|
618
|
-
**2. An interaction is *accepted*, not awaited.** The click returns the moment the
|
|
619
|
-
command is on the task's shard: a handler prop returns `void`, there is no promise
|
|
620
|
-
and no request left to answer — a durable handler may run minutes later, in a
|
|
621
|
-
process the viewer never had a connection to. **Feedback is a re-render**: state
|
|
622
|
-
what happened in the props (`{ submitted: true }`, `{ error: 'unknown address' }`)
|
|
623
|
-
and let the component read them through `ctx.onUpdate`. Two consequences a component
|
|
624
|
-
must respect — it may not lock itself optimistically (nothing on the server changed,
|
|
625
|
-
so no update arrives to unlock it), and a design that needs a synchronous answer
|
|
626
|
-
needs a different shape.
|
|
627
|
-
|
|
628
|
-
**3. Network egress is open, and the sandbox denies credentials.** A component may
|
|
629
|
-
`fetch` any URL and load images, fonts and media from any host (plus `data:` and
|
|
630
|
-
`blob:`); only `script-src` stays closed, so it can send data out but cannot pull
|
|
631
|
-
code in. Its origin is opaque, so it carries **no cookies** and `Origin: null` — it
|
|
632
|
-
acts on its own behalf, never as the viewer, and cannot reach an authenticated
|
|
633
|
-
endpoint of your deployment. Therefore **never put a credential in props for the
|
|
634
|
-
component to fetch with**: the service fetches and re-renders. The flip side is the
|
|
635
|
-
accepted cost — third-party component code holds the props you sent it and can post
|
|
636
|
-
them anywhere, unaudited.
|
|
637
|
-
|
|
638
|
-
## Dev Overlay
|
|
639
|
-
|
|
640
|
-
When developing locally alongside a production service, use a **dev token** (your personal access token) to register a dev instance. Requence routes your own tasks to the dev instance instead of the production pool:
|
|
641
|
-
|
|
642
|
-
```typescript
|
|
643
|
-
import { createService } from '@requence/service'
|
|
644
|
-
|
|
645
|
-
createService(
|
|
646
|
-
{
|
|
647
|
-
version: '1.0.0',
|
|
648
|
-
devToken: process.env.REQUENCE_DEV_TOKEN,
|
|
649
|
-
},
|
|
650
|
-
(ctx) => ctx.input,
|
|
651
|
-
)
|
|
652
|
-
```
|
|
653
|
-
|
|
654
|
-
The dev token is resolved in the same order as the access token — option, `REQUENCE_SERVICE_DEV_TOKEN` / `REQUENCE_DEV_TOKEN` env var, or `requence.service.devToken` / `requence.devToken` in `package.json`.
|
|
655
|
-
|
|
656
|
-
### Component reload
|
|
657
|
-
|
|
658
|
-
A dev instance watches the built file behind every `createUI`. Rebuild it and the
|
|
659
|
-
frame swaps to the new bundle in place — same props, same handlers, same node,
|
|
660
|
-
no re-render and no new task.
|
|
661
|
-
|
|
662
|
-
It reaches every surface your dev session ever drew, and nothing else: a live
|
|
663
|
-
panel, a form on a node that is still deferred, and a surface on a task that
|
|
664
|
-
finished last week all swap alike, while a production surface of the same
|
|
665
|
-
component is untouched. Restarting your service does not strand the surfaces it
|
|
666
|
-
drew a moment before.
|
|
667
|
-
|
|
668
|
-
One limit worth knowing: what swaps is the code, never the props. A component
|
|
669
|
-
that only shows what the last render sent it will show exactly that, until your
|
|
670
|
-
service renders again.
|
|
671
|
-
|
|
672
|
-
While your dev process is stopped, the surfaces it drew report their handlers as
|
|
673
|
-
unreachable and say so in those terms — *the dev process that drew this surface is
|
|
674
|
-
not running* — not as a missing deployment. Start it again and they come back.
|
|
675
|
-
|
|
676
|
-
## CLI — Generate Types
|
|
677
|
-
|
|
678
|
-
The package ships with a CLI that generates TypeScript types derived from the schemas you defined in the Requence UI (input, configuration, and outputs):
|
|
48
|
+
Generate types for `ctx.input`, `ctx.configuration` and `ctx.toOutput()` from the schemas you
|
|
49
|
+
defined in the UI:
|
|
679
50
|
|
|
680
51
|
```bash
|
|
681
52
|
npx requence-service generate-types
|
|
682
53
|
```
|
|
683
54
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
|
689
|
-
|
|
690
|
-
|
|
|
691
|
-
|
|
|
692
|
-
|
|
|
693
|
-
|
|
|
694
|
-
|
|
|
695
|
-
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
createService('1.0.0', async function* (ctx) {
|
|
715
|
-
for await (const event of wrapIterable(eventStream)) {
|
|
716
|
-
yield processEvent(event)
|
|
717
|
-
}
|
|
718
|
-
})
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
### `asyncEventEmitter(initial?)`
|
|
722
|
-
|
|
723
|
-
Creates an async-iterable event emitter that integrates with `ctx.terminated`. Push values from outside (e.g. callbacks or event listeners) and iterate them inside the generator:
|
|
724
|
-
|
|
725
|
-
```typescript
|
|
726
|
-
import { asyncEventEmitter, createService } from '@requence/service'
|
|
727
|
-
|
|
728
|
-
createService('1.0.0', async function* (ctx) {
|
|
729
|
-
const emitter = asyncEventEmitter<string>()
|
|
730
|
-
|
|
731
|
-
externalSource.on('data', (value) => emitter.push(value))
|
|
732
|
-
|
|
733
|
-
for await (const value of emitter) {
|
|
734
|
-
yield { value }
|
|
735
|
-
}
|
|
736
|
-
})
|
|
737
|
-
```
|
|
738
|
-
|
|
739
|
-
The iterator exits automatically when the task is terminated.
|
|
740
|
-
|
|
741
|
-
## Full Example
|
|
742
|
-
|
|
743
|
-
```typescript
|
|
744
|
-
import { createService } from '@requence/service'
|
|
745
|
-
import { db } from './database.js'
|
|
746
|
-
|
|
747
|
-
createService(
|
|
748
|
-
{
|
|
749
|
-
version: '1.2.3',
|
|
750
|
-
prefetch: 2, // process 2 messages in parallel
|
|
751
|
-
},
|
|
752
|
-
async (ctx) => {
|
|
753
|
-
if (!db.isConnected) {
|
|
754
|
-
ctx.retry(2_000) // wait 2s for the DB to recover
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (!ctx.input.ocrData) {
|
|
758
|
-
ctx.abort('OCR data is mandatory')
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const result = await db.getDataBasedOnOcr(ctx.input.ocrData)
|
|
762
|
-
|
|
763
|
-
return result
|
|
764
|
-
},
|
|
765
|
-
)
|
|
766
|
-
```
|
|
55
|
+
## Where to read next
|
|
56
|
+
|
|
57
|
+
| Topic | Page |
|
|
58
|
+
| ------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
|
59
|
+
| The full walkthrough | [Getting Started][started] |
|
|
60
|
+
| `ctx.input`, `ctx.debug`, `retry`, `abort`, `skip`, `toOutput`, `defer` | [Context API][context] |
|
|
61
|
+
| `prefetch`, `connectionTimeout`, `silent`, TLS via environment | [Service Options][options] |
|
|
62
|
+
| `service.open()`, `service.close()` | [Starting & Stopping][lifecycle] |
|
|
63
|
+
| Generator handlers and `ctx.terminated` | [Continuous Mode][continuous] |
|
|
64
|
+
| `ctx.store` — what a node remembers past this message | [The Store][store] · [`ctx.store`][context-store] |
|
|
65
|
+
| `createDurableCallback`, `.for(…)`, the per-version queue | [Callbacks][callbacks] |
|
|
66
|
+
| `createUI`, `ctx.render`, handler props | [Surfaces][surfaces] |
|
|
67
|
+
| `ctx.defer()` → `service.act()` | [Deferred Delivery][deferred] |
|
|
68
|
+
| `wrapIterable`, `asyncEventEmitter` | [Utility Helpers][utilities] |
|
|
69
|
+
| Running locally against real tasks | [Dev Overlay][overlay] |
|
|
70
|
+
| `requence-service generate-types` | [Typing / CLI][typing] |
|
|
71
|
+
|
|
72
|
+
[started]: https://docs.requence.cloud/implementation/getting-started/
|
|
73
|
+
[context]: https://docs.requence.cloud/implementation/context-api/
|
|
74
|
+
[context-store]: https://docs.requence.cloud/implementation/context-api/#ctxstoreprivatekey-binding--ctxstoresharedkey-binding
|
|
75
|
+
[options]: https://docs.requence.cloud/implementation/advanced/service-options/
|
|
76
|
+
[lifecycle]: https://docs.requence.cloud/implementation/advanced/
|
|
77
|
+
[continuous]: https://docs.requence.cloud/planning/continuous-mode/
|
|
78
|
+
[store]: https://docs.requence.cloud/planning/store/
|
|
79
|
+
[callbacks]: https://docs.requence.cloud/implementation/callbacks/
|
|
80
|
+
[surfaces]: https://docs.requence.cloud/implementation/surfaces/
|
|
81
|
+
[deferred]: https://docs.requence.cloud/implementation/advanced/deferred-delivery/
|
|
82
|
+
[utilities]: https://docs.requence.cloud/implementation/advanced/utilities/
|
|
83
|
+
[overlay]: https://docs.requence.cloud/implementation/advanced/dev-overlay/
|
|
84
|
+
[typing]: https://docs.requence.cloud/implementation/typing-cli/
|