@requence/service 0.8.2-edge.1159 → 0.8.2-edge.1162

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # @requence/service
2
2
 
3
- This package connects a TypeScript / Bun service to the Requence operator. It manages message retrieval, processing, and result delivery.
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 the Requence UI by clicking **Copy credentials**.
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
- ## Usage
26
-
27
- ```typescript
28
- import { createService } from '@requence/service'
31
+ ## Your first service
29
32
 
30
- createService('1.0.0', (ctx) => {
31
- return { message: `Hello, ${ctx.input.name}!` }
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
- const messageKey = ctx.defer('waiting for external process')
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
- A durable handler gets **no injected context**: it reaches `service.act()`, your
538
- stores, anything else through its own closure, exactly as a module-scope function
539
- would in any other process of the version. An invocation naming a component or a
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
- async function attach(key: string) {
580
- const clicks = asyncEventEmitter<{ timestamp: number }>()
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
- This creates a `requence-env.d.ts` file in your project root. TypeScript picks it up automatically — `ctx.input`, `ctx.configuration`, and `ctx.toOutput()` calls become fully typed.
685
-
686
- ### Options
687
-
688
- | Option | Default | Description |
689
- |---|---|---|
690
- | `--access-token` | — | Service access token (falls back to env / config file) |
691
- | `--dev-token` | — | Personal access token for branch-specific types |
692
- | `--outfile` | `requence-env.d.ts` | Name of the generated type file |
693
- | `--outdir` | `.` | Directory to write the type file to |
694
- | `--watch` | `false` | Watch for schema changes and regenerate automatically |
695
- | `--clear` / `--no-clear` | `true` | Clear the terminal on watch updates |
696
-
697
- ### Watch mode
698
-
699
- ```bash
700
- npx requence-service generate-types --watch
701
- ```
702
-
703
- The CLI connects via server-sent events and regenerates types whenever a schema changes in the UI.
704
-
705
- ## Utility Helpers
706
-
707
- ### `wrapIterable(iterable)`
708
-
709
- Wraps any `Iterable` or `AsyncIterable` so that iteration stops automatically when `ctx.terminated` resolves:
710
-
711
- ```typescript
712
- import { wrapIterable } from '@requence/service'
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/