stitchkit 0.49.2 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3 -3
- package/dist/contract/errors-factory.d.ts +27 -6
- package/dist/contract/errors-factory.d.ts.map +1 -1
- package/dist/contract/factory.d.ts +15 -2
- package/dist/contract/factory.d.ts.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -1
- package/dist/{index-z992nfbx.js → index-8mt47qk7.js} +127 -123
- package/dist/{index-jewp9r0a.js → index-8qghvg18.js} +1 -1
- package/dist/{index-h05ygjqx.js → index-bmnhqtya.js} +4 -1
- package/dist/{index-p9d4cxt5.js → index-gff2mxzk.js} +1 -1
- package/dist/{index-0hj37z43.js → index-h55e1wyq.js} +2 -2
- package/dist/{index-v58mwa19.js → index-psrxjvbw.js} +2 -2
- package/dist/{index-45dz4m51.js → index-trz4ate5.js} +6 -2
- package/dist/{index-03j2t778.js → index-tw7mhqgy.js} +15 -4
- package/dist/index-ynts85ew.js +246 -0
- package/dist/index.js +1 -1
- package/dist/node.d.ts +3 -2
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +15 -6
- package/dist/observability/index.js +3 -3
- package/dist/server/implement.d.ts +101 -8
- package/dist/server/implement.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +19 -11
- package/dist/server/middleware/auth.d.ts +63 -3
- package/dist/server/middleware/auth.d.ts.map +1 -1
- package/dist/server/process-signals.d.ts +117 -0
- package/dist/server/process-signals.d.ts.map +1 -0
- package/dist/server/types.d.ts +38 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/testing.js +2 -2
- package/dist/tools/list-names.d.ts +13 -1
- package/dist/tools/list-names.d.ts.map +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +13 -8
- package/llms-full.txt +404 -39
- package/package.json +1 -1
- package/dist/index-r1qp4rve.js +0 -57
package/llms-full.txt
CHANGED
|
@@ -617,6 +617,21 @@ the exact config without `NonNullable` or another wrapper. Plain `ContractDef`
|
|
|
617
617
|
keeps optional scope because ordinary `defineContract` still supports its
|
|
618
618
|
default-public overload.
|
|
619
619
|
|
|
620
|
+
The union covers **per-endpoint overrides too**, not just the contract's scope —
|
|
621
|
+
that is where typos are densest, and one there used to compile and then fail
|
|
622
|
+
closed at request time (`[stitchkit] auth: no rule for scope "…"`):
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
defineContract({ prefix: 'posts', scope: 'user' }, {
|
|
626
|
+
drop: { method: 'DELETE', path: '/:id', desc: 'Drop', scope: 'admn', … },
|
|
627
|
+
// ^ compile error, and TypeScript
|
|
628
|
+
// suggests 'admin'
|
|
629
|
+
})
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
Contracts built with plain `defineContract` are unaffected — their endpoint
|
|
633
|
+
`scope` stays a free string.
|
|
634
|
+
|
|
620
635
|
## One source of truth
|
|
621
636
|
|
|
622
637
|
A contract is plain data — no classes, no decorators, no codegen. It is imported
|
|
@@ -686,6 +701,79 @@ export const implement = createImplement<AppContext>()
|
|
|
686
701
|
// every implement() call now has ctx.user typed
|
|
687
702
|
```
|
|
688
703
|
|
|
704
|
+
### Per-scope handler context — `createScopedImplement`
|
|
705
|
+
|
|
706
|
+
`createImplement<Ctx>()` gives every handler the **same** context type. In an app
|
|
707
|
+
where each scope guarantees different injected fields, that single type is a
|
|
708
|
+
superset: a `public` handler is told it has a `userId` the runtime never injects
|
|
709
|
+
there. `createScopedImplement` types each handler by its endpoint's **effective**
|
|
710
|
+
scope instead — the endpoint's own `scope`, else the contract's:
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
import { createScopedImplement } from 'stitchkit/server'
|
|
714
|
+
|
|
715
|
+
export const implementFor = createScopedImplement<{
|
|
716
|
+
public: object // no extra fields
|
|
717
|
+
user: { userId: string }
|
|
718
|
+
admin: { userId: string; isAdmin: boolean }
|
|
719
|
+
}>()
|
|
720
|
+
|
|
721
|
+
implementFor(postsContract, {
|
|
722
|
+
list: (ctx) => listPosts(ctx.userId), // contract scope 'user'
|
|
723
|
+
purge: (ctx) => (ctx.isAdmin ? purge() : []), // endpoint scope 'admin'
|
|
724
|
+
ping: () => ({ ok: true }), // endpoint scope 'public'
|
|
725
|
+
})
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
The map is **type-only** — scope fields are types, so there is no runtime
|
|
729
|
+
argument and no `{} as UserFields` at the call site. `'public'` must be a key:
|
|
730
|
+
a contract without a `scope` is `'public'`. A scope outside the map is a compile
|
|
731
|
+
error on that endpoint, naming the scope.
|
|
732
|
+
|
|
733
|
+
Three boundaries worth knowing:
|
|
734
|
+
|
|
735
|
+
- A field of another scope is not a compile error on access — `RuntimeContext`
|
|
736
|
+
keeps its `[key: string]: unknown` index signature (transports write through
|
|
737
|
+
it). It degrades to `unknown`, so it can no longer pose as a `string`; using it
|
|
738
|
+
in a typed position fails.
|
|
739
|
+
- The map states what **your** `beforeHandle` / `createAuthHook.inject` puts on
|
|
740
|
+
the context. The framework does not verify a hand-written claim (→ ADR 0075) —
|
|
741
|
+
so prefer not to write one: scoped auth rules declare their contribution by
|
|
742
|
+
performing it, and `createScopedImplement<AuthScopes<typeof hook>>()` derives
|
|
743
|
+
the map from the hook
|
|
744
|
+
([details](./auth-and-errors.md#scoped-rules--the-map-createscopedimplement-consumes),
|
|
745
|
+
→ ADR 0078).
|
|
746
|
+
- An endpoint hoisted out of the contract literal widens its `scope` to `string`,
|
|
747
|
+
which is no key of any map, and lands in the error branch. An endpoint whose
|
|
748
|
+
`scope` is added by a conditional spread is optional, so it resolves to **both**
|
|
749
|
+
scopes and is typed against what they guarantee in common. Write endpoints
|
|
750
|
+
inline for the precise type.
|
|
751
|
+
- Scope keys are string literals. A map declared as an index signature
|
|
752
|
+
(`Record<string, …>`) makes every scope valid and disables the check.
|
|
753
|
+
|
|
754
|
+
The same map has a registry form and a streaming form:
|
|
755
|
+
|
|
756
|
+
```ts
|
|
757
|
+
// one contract registry, handlers still typed per endpoint scope
|
|
758
|
+
const implementAll = createScopedImplementRegistry<Scopes>()
|
|
759
|
+
export const services = implementAll(apiContractRegistry, { posts, users })
|
|
760
|
+
|
|
761
|
+
// a streaming multipart handler that reads its scope's fields. The endpoint
|
|
762
|
+
// must declare its own `scope`, and only that literal is accepted: an endpoint
|
|
763
|
+
// inheriting the contract's scope cannot be verified from here, and guessing it
|
|
764
|
+
// would rebuild the superset this factory removes.
|
|
765
|
+
implementFor(mediaContract, {
|
|
766
|
+
upload: implementFor.stream('admin', mediaContract.endpoints.upload, {
|
|
767
|
+
files: { file: receiveFile },
|
|
768
|
+
handler: ({ files, userId }) => store(files.file, userId),
|
|
769
|
+
}),
|
|
770
|
+
})
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
Outside a scoped app, `createMultipartStream<Ctx>()` does for streaming handlers
|
|
774
|
+
what `createImplement<Ctx>()` does for ordinary ones. Plain
|
|
775
|
+
`defineMultipartStream` still gives the loose `RuntimeContext`.
|
|
776
|
+
|
|
689
777
|
### `implementRegistry` — one backend registry
|
|
690
778
|
|
|
691
779
|
When contracts already live in one literal registry, bind the backend from that
|
|
@@ -702,7 +790,17 @@ export const apiServices = implementRegistry(apiContractRegistry, {
|
|
|
702
790
|
|
|
703
791
|
Every registry key is required, extra keys fail, and each handler map is checked
|
|
704
792
|
against its own contract. The returned service order follows the contract
|
|
705
|
-
registry order
|
|
793
|
+
registry order — and the same services ride along under `.byKey`, so a consumer
|
|
794
|
+
whose registry keys are load-bearing (filtering a tool surface per caller) never
|
|
795
|
+
rebuilds a prefix lookup by hand:
|
|
796
|
+
|
|
797
|
+
```ts
|
|
798
|
+
const services = implementRegistry(apiContractRegistry, handlers)
|
|
799
|
+
createServer({ services }) // the array, as before
|
|
800
|
+
const visible = [services.byKey.users] // the same objects, by key
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
`createImplementRegistry<AppContext>()` fixes the application
|
|
706
804
|
context once, like `createImplement` does for a single contract. Runtime callers
|
|
707
805
|
also fail first on missing/extra keys and duplicate contract prefixes.
|
|
708
806
|
|
|
@@ -974,9 +1072,11 @@ createServer({
|
|
|
974
1072
|
|
|
975
1073
|
A prefix may carry `:param` segments (they land on the context exactly as above).
|
|
976
1074
|
Services listed under explicit `groups` are unaffected — the group prefix wins.
|
|
977
|
-
Scope stays a free string
|
|
978
|
-
(
|
|
979
|
-
|
|
1075
|
+
Scope stays a free string (unless the contract is authored through
|
|
1076
|
+
`createContractFactory<Scope>()`); the core attaches no meaning beyond this lookup
|
|
1077
|
+
(→ ADR 0024). When each scope needs a different handler-context shape, use
|
|
1078
|
+
[`createScopedImplement`](#per-scope-handler-context--createscopedimplement)
|
|
1079
|
+
rather than a single superset context.
|
|
980
1080
|
|
|
981
1081
|
## Lifecycle hooks
|
|
982
1082
|
|
|
@@ -3578,7 +3678,9 @@ the scope vocabulary are yours.
|
|
|
3578
3678
|
## Scopes
|
|
3579
3679
|
|
|
3580
3680
|
An endpoint declares a `scope` — a free string. The contract `meta.scope` is the
|
|
3581
|
-
default for endpoints that declare none.
|
|
3681
|
+
default for endpoints that declare none. (Authored through
|
|
3682
|
+
[`createContractFactory<Scope>()`](./contracts.md#scope), both the contract's
|
|
3683
|
+
scope and any endpoint override are held to your own union instead.)
|
|
3582
3684
|
|
|
3583
3685
|
```ts
|
|
3584
3686
|
export const posts = defineContract({ prefix: 'posts', scope: 'user' }, {
|
|
@@ -3668,7 +3770,61 @@ endpoints under the group declare `scope: 'tenant'`.
|
|
|
3668
3770
|
| `onForbidden` | thrown when an identity is present but the rule rejects it (default 403) |
|
|
3669
3771
|
|
|
3670
3772
|
Annotate `rules` with `satisfies Record<MyScope, AuthRule<User>>` so the compiler
|
|
3671
|
-
catches a scope you forgot to cover.
|
|
3773
|
+
catches a scope you forgot to cover. With the scoped rule objects below, widen
|
|
3774
|
+
the annotation to
|
|
3775
|
+
`satisfies Record<MyScope, AuthRule<User> | ScopedAuthRule<User, object>>` — it
|
|
3776
|
+
keeps the coverage check and does not disturb the derivation. (When every scope
|
|
3777
|
+
comes from the derived map, the check is already implicit: a scope missing from
|
|
3778
|
+
`rules` is no key of the map, and a contract using it fails to compile.)
|
|
3779
|
+
|
|
3780
|
+
### Scoped rules — the map `createScopedImplement` consumes
|
|
3781
|
+
|
|
3782
|
+
A rule may take an object form, `{ rule, inject }`, where `inject` **returns**
|
|
3783
|
+
the fields this scope contributes. That return type is a declaration the hook
|
|
3784
|
+
derives a scope→context map from — feed it to
|
|
3785
|
+
[`createScopedImplement`](./server.md#per-scope-handler-context--createscopedimplement)
|
|
3786
|
+
and the map can never drift from the hook, because it is computed from it:
|
|
3787
|
+
|
|
3788
|
+
```ts
|
|
3789
|
+
const authHook = createAuthHook({
|
|
3790
|
+
resolve: sessionResolver, // no explicit generic — let it infer
|
|
3791
|
+
rules: {
|
|
3792
|
+
public: { rule: 'public', inject: (user) => ({ userId: user.id }) },
|
|
3793
|
+
user: { rule: 'authenticated', inject: (user) => ({ userId: user.id }) },
|
|
3794
|
+
admin: {
|
|
3795
|
+
rule: (user) => user.admin,
|
|
3796
|
+
inject: (user) => ({ userId: user.id, isAdmin: user.admin }),
|
|
3797
|
+
},
|
|
3798
|
+
},
|
|
3799
|
+
})
|
|
3800
|
+
|
|
3801
|
+
export const implementFor = createScopedImplement<AuthScopes<typeof authHook>>()
|
|
3802
|
+
```
|
|
3803
|
+
|
|
3804
|
+
- A `'public'` rule's fields come out **optional**: public admits the anonymous
|
|
3805
|
+
caller, it does not refuse to know the logged-in one — `resolve` and `inject`
|
|
3806
|
+
still run, so a public `me` / `logout` handler reads `ctx.userId` as
|
|
3807
|
+
`string | undefined` and narrows, instead of being pushed toward a cast by a
|
|
3808
|
+
`public: object` map.
|
|
3809
|
+
- Any other rule's fields are required — the rule rejected the request before
|
|
3810
|
+
the handler if no identity resolved.
|
|
3811
|
+
- A scope with no rule is no key of the derived map, so a contract using it
|
|
3812
|
+
fails to compile — the type-level mirror of the hook's fail-closed
|
|
3813
|
+
`no rule for scope` throw.
|
|
3814
|
+
|
|
3815
|
+
The per-rule `inject` runs after the shared one, only when an identity
|
|
3816
|
+
resolved, and **before** the rule check — it may run for an identity the rule
|
|
3817
|
+
then rejects, so keep it pure and synchronous (an async `inject` is a compile
|
|
3818
|
+
error, and a runtime one for untyped callers: merging a Promise would merge
|
|
3819
|
+
nothing). Write rule objects inline in `rules` — a hoisted, annotated rule
|
|
3820
|
+
widens its inject and degrades that scope's fields to `object`.
|
|
3821
|
+
|
|
3822
|
+
Derivation needs the identity generic to be **inferred** (write no explicit
|
|
3823
|
+
`createAuthHook<User>` — TypeScript has no partial inference); with an explicit
|
|
3824
|
+
generic, or fields injected outside the hook, keep the hand-written map. Two
|
|
3825
|
+
hooks guarding different scopes compose by intersection:
|
|
3826
|
+
`createScopedImplement<AuthScopes<typeof userHook> & AuthScopes<typeof botHook>>()`.
|
|
3827
|
+
→ ADR 0078
|
|
3672
3828
|
|
|
3673
3829
|
### Auth on the tool surface — `resolveFromContext`
|
|
3674
3830
|
|
|
@@ -3870,36 +4026,57 @@ onError: (ctx, err) => {
|
|
|
3870
4026
|
|
|
3871
4027
|
## Domain errors — `defineErrors`
|
|
3872
4028
|
|
|
3873
|
-
Declare each domain code, HTTP status and optional
|
|
3874
|
-
one immutable registry. The generated functions
|
|
3875
|
-
`AppError` instances; ordinary `throw` remains explicit
|
|
4029
|
+
Declare each domain code, HTTP status, default message and optional
|
|
4030
|
+
structured-details schema in one immutable registry. The generated functions
|
|
4031
|
+
construct typed branded `AppError` instances; ordinary `throw` remains explicit
|
|
4032
|
+
at the call site:
|
|
3876
4033
|
|
|
3877
4034
|
```ts
|
|
3878
4035
|
import { z } from 'zod'
|
|
3879
4036
|
|
|
3880
4037
|
export const { errors, codes, definitions, isCode } = defineErrors({
|
|
3881
|
-
SESSION_NOT_FOUND: { status: 404 },
|
|
4038
|
+
SESSION_NOT_FOUND: { status: 404, message: 'No such session' },
|
|
3882
4039
|
QUOTA_EXCEEDED: {
|
|
3883
4040
|
status: 429,
|
|
4041
|
+
message: 'Monthly quota exhausted',
|
|
3884
4042
|
details: z.object({ retryAfterSeconds: z.number().int().positive() }),
|
|
3885
4043
|
},
|
|
3886
4044
|
})
|
|
3887
4045
|
|
|
3888
|
-
throw errors.SESSION_NOT_FOUND(
|
|
4046
|
+
throw errors.SESSION_NOT_FOUND() // uses the declared message
|
|
3889
4047
|
throw errors.QUOTA_EXCEEDED({
|
|
3890
|
-
message: 'Try later',
|
|
4048
|
+
message: 'Try later', // overrides it here only
|
|
3891
4049
|
details: { retryAfterSeconds: 30 },
|
|
3892
4050
|
hint: 'Wait for the current window to expire',
|
|
3893
4051
|
})
|
|
3894
4052
|
|
|
3895
4053
|
// Construction without throwing is useful for composition or inspection.
|
|
3896
4054
|
const error = errors.QUOTA_EXCEEDED({ details: { retryAfterSeconds: 30 } })
|
|
3897
|
-
definitions.QUOTA_EXCEEDED.status
|
|
4055
|
+
definitions.QUOTA_EXCEEDED.status // 429 — same source, no copied status map
|
|
4056
|
+
definitions[code].message // the declared text, by a `code` variable
|
|
3898
4057
|
|
|
3899
4058
|
// client — match the code, never a magic string
|
|
3900
4059
|
if (err instanceof ApiError && err.code === codes.SESSION_NOT_FOUND) { … }
|
|
3901
4060
|
```
|
|
3902
4061
|
|
|
4062
|
+
`message` is optional: declare it once instead of repeating the sentence at every
|
|
4063
|
+
`throw`, and a per-call `message` still wins. A code without one keeps the
|
|
4064
|
+
existing fallback — `AppError.message` is the code itself. An empty declared
|
|
4065
|
+
message is rejected when the registry is declared, not when the error is thrown.
|
|
4066
|
+
|
|
4067
|
+
**The tool envelope has no `message` field**, and what the model sees depends on
|
|
4068
|
+
whether the code declares `details`:
|
|
4069
|
+
|
|
4070
|
+
| | HTTP / typed client | MCP / agent / CLI |
|
|
4071
|
+
|---|---|---|
|
|
4072
|
+
| code **with** a `details` schema | the declared message | no text at all — only `details` and `hint` |
|
|
4073
|
+
| code **without** one | the declared message | the same text, delivered as `details.message` |
|
|
4074
|
+
|
|
4075
|
+
The model-facing envelope is `{ error, details?, _hint? }`; for a code with no
|
|
4076
|
+
details schema the framework fills `details` with `{ message }`, so declaring a
|
|
4077
|
+
message changes what the model reads there — it used to be the code itself. Put
|
|
4078
|
+
anything the model must reliably read in `details` or `hint`, not in `message`.
|
|
4079
|
+
|
|
3903
4080
|
With no `details` schema, the options object forbids `details`. A required
|
|
3904
4081
|
`z.object` makes `details` required; `z.object(...).optional()` makes it
|
|
3905
4082
|
optional. Supplied details are parsed when the error is constructed, before any
|
|
@@ -4637,34 +4814,123 @@ createServer({
|
|
|
4637
4814
|
})
|
|
4638
4815
|
```
|
|
4639
4816
|
|
|
4640
|
-
|
|
4817
|
+
### Process signals — `bindProcessSignals`
|
|
4818
|
+
|
|
4819
|
+
Keep the managed handle and wire process policy explicitly. The framework
|
|
4820
|
+
registers no signal listener on its own; `bindProcessSignals` is that explicit
|
|
4821
|
+
step, and it owns the state machine:
|
|
4641
4822
|
|
|
4642
4823
|
```ts
|
|
4824
|
+
import { bindProcessSignals } from 'stitchkit/server'
|
|
4825
|
+
|
|
4643
4826
|
const server = createServer({ services, socket })
|
|
4644
|
-
const force = new AbortController()
|
|
4645
|
-
let closing: Promise<void> | undefined
|
|
4646
4827
|
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
}
|
|
4652
|
-
closing = server.shutdown({
|
|
4653
|
-
gracePeriodMs: 30_000,
|
|
4654
|
-
forceTimeoutMs: 5_000,
|
|
4655
|
-
signal: force.signal,
|
|
4656
|
-
}).then(async result => {
|
|
4828
|
+
bindProcessSignals(server, {
|
|
4829
|
+
shutdown: { gracePeriodMs: 30_000, forceTimeoutMs: 5_000 },
|
|
4830
|
+
onShutdown: () => stopSchedulers(), // before the drain starts
|
|
4831
|
+
onComplete: async (result) => { // after it finishes
|
|
4657
4832
|
await mcp.close()
|
|
4658
4833
|
await prisma.$disconnect()
|
|
4659
|
-
|
|
4660
|
-
}
|
|
4661
|
-
|
|
4834
|
+
process.exitCode = result.outcome === 'clean' ? 0 : 1
|
|
4835
|
+
},
|
|
4836
|
+
onError: (error) => {
|
|
4837
|
+
console.error(error)
|
|
4838
|
+
process.exitCode = 1
|
|
4839
|
+
},
|
|
4840
|
+
})
|
|
4841
|
+
```
|
|
4842
|
+
|
|
4843
|
+
- The first signal runs `onShutdown`, then one `shutdown()`.
|
|
4844
|
+
- A later signal **forces that same chain** — never a second shutdown. Signals
|
|
4845
|
+
delivered in the same turn as the first (a supervisor sending `SIGINT` and
|
|
4846
|
+
`SIGTERM` together) are not counted, so the grace period is not collapsed to
|
|
4847
|
+
zero.
|
|
4848
|
+
- The signal after the force — or one arriving while `onComplete` still runs —
|
|
4849
|
+
re-delivers the signal so its default disposition applies, letting an operator
|
|
4850
|
+
kill a process stuck on some other resource. This works only while nothing else
|
|
4851
|
+
in the process listens for that signal; if something does, the signal is
|
|
4852
|
+
swallowed and `onEscalationBlocked` fires instead. The framework will not call
|
|
4853
|
+
`process.exit` on your behalf.
|
|
4854
|
+
- `shutdown` budgets are validated when you bind, not when the signal arrives.
|
|
4855
|
+
`signal` is not accepted there — the binding owns it, and that is what makes a
|
|
4856
|
+
later signal force the first chain.
|
|
4857
|
+
- `onError(phase, error)` separates the phases. A failing `onShutdown`
|
|
4858
|
+
(`'prepare'`) is reported and the shutdown **still runs** — a failed
|
|
4859
|
+
preparation must not leave the server listening. A failing `onComplete`
|
|
4860
|
+
(`'complete'`) leaves `promise` resolved, because the transport did shut down.
|
|
4861
|
+
Only a failing `shutdown()` (`'shutdown'`) rejects it.
|
|
4862
|
+
|
|
4863
|
+
`close()` removes the listeners. Closing a binding that never received a signal
|
|
4864
|
+
resolves `promise` with `undefined`, so an awaiting application does not hang;
|
|
4865
|
+
closing one whose chain is already running leaves that chain to finish and keeps
|
|
4866
|
+
the handle claimed, since a second binding could not force it. `promise` is
|
|
4867
|
+
already observed internally, so ignoring it never raises an `unhandledRejection`.
|
|
4868
|
+
|
|
4869
|
+
#### Composite shutdown target — parallel domain drains
|
|
4870
|
+
|
|
4871
|
+
`bindProcessSignals` takes `Pick<ManagedServerHandle, 'shutdown'>` — an
|
|
4872
|
+
**interface**, not the server. An application whose shutdown spans several
|
|
4873
|
+
domains (transport, bots, agent runs, broadcasts) composes them into one target;
|
|
4874
|
+
the signal machine stays the framework's, the composition stays yours:
|
|
4875
|
+
|
|
4876
|
+
```ts
|
|
4877
|
+
const composite: ShutdownTarget = {
|
|
4878
|
+
async shutdown(options?: ShutdownOptions): Promise<ShutdownResult> {
|
|
4879
|
+
const { signal } = ShutdownOptionsSchema.parse(options ?? {})
|
|
4880
|
+
// Domain drains run in parallel with the transport drain, all watching the
|
|
4881
|
+
// same force signal the second OS signal aborts.
|
|
4882
|
+
const [transport, bots, agents, broadcasts] = await Promise.all([
|
|
4883
|
+
server.shutdown(options),
|
|
4884
|
+
drainBots(signal),
|
|
4885
|
+
drainAgents(signal),
|
|
4886
|
+
drainBroadcasts(signal),
|
|
4887
|
+
])
|
|
4888
|
+
return {
|
|
4889
|
+
...transport,
|
|
4890
|
+
// Fold domain leftovers into the transport result so `onComplete` sees
|
|
4891
|
+
// one honest outcome.
|
|
4892
|
+
outcome: transport.outcome === 'clean' && bots + agents + broadcasts === 0
|
|
4893
|
+
? 'clean'
|
|
4894
|
+
: 'forced',
|
|
4895
|
+
}
|
|
4896
|
+
},
|
|
4662
4897
|
}
|
|
4663
4898
|
|
|
4664
|
-
|
|
4665
|
-
|
|
4899
|
+
bindProcessSignals(composite, { shutdown: { gracePeriodMs: 30_000 } })
|
|
4900
|
+
```
|
|
4901
|
+
|
|
4902
|
+
The transport half inside stays the real managed handle — its admission gate and
|
|
4903
|
+
HTTP drain are not reimplemented, only composed. Domain drains must watch the
|
|
4904
|
+
`signal` themselves: it is the only channel a second OS signal has into a
|
|
4905
|
+
running chain.
|
|
4906
|
+
|
|
4907
|
+
#### The last line of defence is yours
|
|
4908
|
+
|
|
4909
|
+
The framework never calls `process.exit`, and `process.exitCode` only takes
|
|
4910
|
+
effect once the event loop empties — which is exactly what a stuck resource
|
|
4911
|
+
prevents. If the deployment must exit before the supervisor's `SIGKILL`, arm an
|
|
4912
|
+
application-side timer:
|
|
4913
|
+
|
|
4914
|
+
```ts
|
|
4915
|
+
onShutdown: () => {
|
|
4916
|
+
setTimeout(() => process.exit(1), 60_000).unref()
|
|
4917
|
+
},
|
|
4666
4918
|
```
|
|
4667
4919
|
|
|
4920
|
+
`unref()` keeps the timer from holding a healthy process open; it only fires if
|
|
4921
|
+
something else already is.
|
|
4922
|
+
|
|
4923
|
+
#### When NOT to use `bindProcessSignals`
|
|
4924
|
+
|
|
4925
|
+
- The application already runs a signal machine with states this one does not
|
|
4926
|
+
have (staged escalation policies, per-signal semantics beyond
|
|
4927
|
+
first/force/escalate). Two machines on the same signals is worse than either.
|
|
4928
|
+
- Signal policy must differ per signal (e.g. `SIGHUP` = reload, not shutdown) —
|
|
4929
|
+
bind only the terminating signals here and keep the rest yours.
|
|
4930
|
+
- Something else in the process must keep listening for the same signal: the
|
|
4931
|
+
escalation path restores the default disposition only when nothing else
|
|
4932
|
+
listens, and reports `onEscalationBlocked` otherwise.
|
|
4933
|
+
|
|
4668
4934
|
The server owns HTTP/Socket.IO transport resources. MCP, databases, queues and
|
|
4669
4935
|
domain run-state remain application resources and close explicitly after server
|
|
4670
4936
|
drain. Do not call `runtime.stop()` or `socket.io.close()` in parallel with
|
|
@@ -4797,10 +5063,20 @@ createServer({
|
|
|
4797
5063
|
Handlers read `ctx.tenantId` (a raw `string` — narrow it). To get it typed inside
|
|
4798
5064
|
`ctx.params`, add `tenantId` to the endpoint's `params` schema (a `z.strictObject`
|
|
4799
5065
|
that omits it will reject the request). When each scope guarantees different
|
|
4800
|
-
injected fields,
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
5066
|
+
injected fields, declare the scope map once with
|
|
5067
|
+
[`createScopedImplement`](./server.md#per-scope-handler-context--createscopedimplement):
|
|
5068
|
+
|
|
5069
|
+
```ts
|
|
5070
|
+
export const implementFor = createScopedImplement<{
|
|
5071
|
+
public: object
|
|
5072
|
+
tenant: { tenantId: string; userId: string }
|
|
5073
|
+
project: { projectId: string; userId: string }
|
|
5074
|
+
}>()
|
|
5075
|
+
```
|
|
5076
|
+
|
|
5077
|
+
Each handler is then typed by its endpoint's effective scope — no superset
|
|
5078
|
+
context that promises a `tenantId` a `public` handler never receives. A contract
|
|
5079
|
+
that mixes scopes across its endpoints needs no splitting.
|
|
4804
5080
|
|
|
4805
5081
|
## 3. Auth — gate the tenant in the path
|
|
4806
5082
|
|
|
@@ -4990,6 +5266,69 @@ current one *up to* your target, and apply each snippet.
|
|
|
4990
5266
|
runtime): bootstrap the server, one HTTP request, and any feature you rely on
|
|
4991
5267
|
(Socket.IO connect, an MCP tool call, a multipart upload, …).
|
|
4992
5268
|
|
|
5269
|
+
## Released migration: 0.50.0
|
|
5270
|
+
|
|
5271
|
+
### The factory's scope union now covers per-endpoint overrides
|
|
5272
|
+
|
|
5273
|
+
`createContractFactory<Scope>()` already required a typed `scope` on the
|
|
5274
|
+
contract. It now holds a per-endpoint `scope` override to the same union. Nothing
|
|
5275
|
+
to migrate unless an override is outside the union — which was always a bug: the
|
|
5276
|
+
scope reached no auth rule, and `createAuthHook` threw
|
|
5277
|
+
`[stitchkit] auth: no rule for scope "…"` on the first request to it.
|
|
5278
|
+
|
|
5279
|
+
```ts
|
|
5280
|
+
const { defineContract } = createContractFactory<'public' | 'user' | 'admin'>()
|
|
5281
|
+
|
|
5282
|
+
defineContract({ prefix: 'posts', scope: 'user' }, {
|
|
5283
|
+
// before: compiled; failed at request time
|
|
5284
|
+
// after: compile error naming the scope (TypeScript even suggests 'admin')
|
|
5285
|
+
purge: { method: 'DELETE', path: '/all', desc: 'Purge', scope: 'admn', output },
|
|
5286
|
+
})
|
|
5287
|
+
```
|
|
5288
|
+
|
|
5289
|
+
Fix the typo, or widen the factory union if the scope is real. Contracts built
|
|
5290
|
+
with plain `defineContract` are unaffected.
|
|
5291
|
+
|
|
5292
|
+
### A declared `defineErrors` message is now used
|
|
5293
|
+
|
|
5294
|
+
`ErrorDefinition` accepts `message`. Nothing to migrate unless a registry already
|
|
5295
|
+
wrote that key: it type-checked before (excess-property checking does not fire
|
|
5296
|
+
through a `const` generic) and was ignored, so the code itself went on the wire.
|
|
5297
|
+
|
|
5298
|
+
```ts
|
|
5299
|
+
const { errors } = defineErrors({ GONE: { status: 410, message: 'Long gone' } })
|
|
5300
|
+
// before: errors.GONE().message === 'GONE'
|
|
5301
|
+
// after: errors.GONE().message === 'Long gone'
|
|
5302
|
+
```
|
|
5303
|
+
|
|
5304
|
+
If a registry carried `message` as a note to the reader rather than as
|
|
5305
|
+
user-facing text, either fix the text or drop the key. A code with no `details`
|
|
5306
|
+
schema also shows that text to a model (its tool `details` is `{ message }`).
|
|
5307
|
+
|
|
5308
|
+
### Optional: adopt `createScopedImplement`
|
|
5309
|
+
|
|
5310
|
+
If the app declares one superset handler context because different scopes inject
|
|
5311
|
+
different fields, replace it with a scope map. This is additive — `createImplement`
|
|
5312
|
+
still works.
|
|
5313
|
+
|
|
5314
|
+
```ts
|
|
5315
|
+
// before — one context for every scope; `ctx.userId` is typed even in a
|
|
5316
|
+
// `public` handler, where the runtime never injects it
|
|
5317
|
+
interface AppContext extends RuntimeContext { userId: string; isAdmin: boolean }
|
|
5318
|
+
export const implement = createImplement<AppContext>()
|
|
5319
|
+
|
|
5320
|
+
// after — each handler typed by its endpoint's effective scope
|
|
5321
|
+
export const implementFor = createScopedImplement<{
|
|
5322
|
+
public: object
|
|
5323
|
+
user: { userId: string }
|
|
5324
|
+
admin: { userId: string; isAdmin: boolean }
|
|
5325
|
+
}>()
|
|
5326
|
+
```
|
|
5327
|
+
|
|
5328
|
+
`'public'` must be a key (a contract with no `scope` is `'public'`). Write
|
|
5329
|
+
endpoints inline in the contract literal: an endpoint hoisted into a variable
|
|
5330
|
+
widens its `scope` to `string` and is reported as undeclared.
|
|
5331
|
+
|
|
4993
5332
|
## Released migration: 0.48.0
|
|
4994
5333
|
|
|
4995
5334
|
### Typed-client request options move to `.withOptions`
|
|
@@ -5919,13 +6258,14 @@ from the root `stitchkit`.
|
|
|
5919
6258
|
| Export | Kind | Summary |
|
|
5920
6259
|
|--------|------|---------|
|
|
5921
6260
|
| `defineContract` | function | declare a contract — [guide](../guide/contracts.md#definecontract) |
|
|
5922
|
-
| `createContractFactory` | function | a `defineContract`
|
|
6261
|
+
| `createContractFactory` | function | a `defineContract` whose scope — on the contract **and** on any endpoint override — is required and held to your union, retaining each concrete literal — [guide](../guide/contracts.md#scope) |
|
|
5923
6262
|
| `ContractFactoryConfig` | _type_ | optional scoped-factory policy, including explicit tool exposure |
|
|
5924
6263
|
| `ContractFactoryToolExposure` | _type_ | `'explicit'` — omitted endpoint exposure materializes as HTTP-only |
|
|
5925
6264
|
| `ExplicitScopedDefineContract` | _type_ | scoped factory authoring with explicit tool opt-in |
|
|
5926
6265
|
| `ExplicitToolExposureEndpoints` | _type_ | endpoint map after missing exposure is materialized as `['HTTP']` |
|
|
6266
|
+
| `FactoryScopedEndpoint` | _type_ | an endpoint authored through the factory — its own `scope` override is held to the same union |
|
|
5927
6267
|
| `ScopedContractDef` | _type_ | a factory-defined contract whose `meta.scope` is the required concrete literal |
|
|
5928
|
-
| `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns |
|
|
6268
|
+
| `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns — endpoint `scope` overrides join the union |
|
|
5929
6269
|
| `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
|
|
5930
6270
|
| `ContractDef` | _type_ | a defined contract |
|
|
5931
6271
|
| `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
|
|
@@ -5975,7 +6315,7 @@ from the root `stitchkit`.
|
|
|
5975
6315
|
| `defineErrors` | function | declare immutable domain error definitions → typed `AppError` constructors, codes and schemas — [guide](../guide/auth-and-errors.md#domain-errors--defineerrors) |
|
|
5976
6316
|
| `DefinedErrors` | _type_ | the `{ errors, codes, definitions, isCode }` handle `defineErrors` returns |
|
|
5977
6317
|
| `DefinedAppError` | _type_ | literal-code error instance with schema-refined details |
|
|
5978
|
-
| `ErrorDefinition` | _type_ | `{ status, details? }` definition for one domain code |
|
|
6318
|
+
| `ErrorDefinition` | _type_ | `{ status, message?, details? }` definition for one domain code |
|
|
5979
6319
|
| `ErrorDefinitions` | _type_ | string-keyed domain error definition registry |
|
|
5980
6320
|
| `ErrorDetailsSchema` | _type_ | required or optional Zod object accepted for structured details |
|
|
5981
6321
|
| `ErrorDetailsOutput` | _type_ | parsed details inferred from one definition |
|
|
@@ -6011,9 +6351,19 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
6011
6351
|
| `createHandler` | function | the router as a bare `(req) => Response` — [guide](../guide/server.md#createserver) |
|
|
6012
6352
|
| `implement` | function | bind a contract to typed handlers — [guide](../guide/server.md#implement) |
|
|
6013
6353
|
| `createImplement` | function | fix the handler context type once |
|
|
6354
|
+
| `createScopedImplement` | function | fix one scope→context map, then type every handler by its endpoint's effective scope — [guide](../guide/server.md#per-scope-handler-context--createscopedimplement) |
|
|
6355
|
+
| `ScopedHandlers` | _type_ | handler map typed per endpoint by its effective scope |
|
|
6356
|
+
| `ScopeContexts` | _type_ | scope → extra context fields map accepted by `createScopedImplement` |
|
|
6357
|
+
| `EffectiveScope` | _type_ | an endpoint's own `scope`, else its contract's group scope |
|
|
6358
|
+
| `createScopedImplementRegistry` | function | the registry form of `createScopedImplement` — one contract registry, handlers still typed per endpoint scope |
|
|
6359
|
+
| `ScopedRegistryHandlers` | _type_ | scoped handler registry inferred from a contract registry |
|
|
6360
|
+
| `ScopedImplementationRegistry` | _type_ | contract registry whose every group scope is a key of the scope map |
|
|
6361
|
+
| `StreamScope` | _type_ | the scope `createScopedImplement(...).stream` accepts for one endpoint |
|
|
6362
|
+
| `ExactScopedRegistryHandlers` | _type_ | fail-first scoped registry shape that rejects extra registry and endpoint keys |
|
|
6014
6363
|
| `implementRegistry` | function | bind one exact contract registry to one exact backend handler registry |
|
|
6015
6364
|
| `createImplementRegistry` | function | context-typed factory for `implementRegistry` |
|
|
6016
6365
|
| `ImplementationRegistry` | _type_ | flat literal registry of concrete contracts accepted by `implementRegistry` |
|
|
6366
|
+
| `KeyedServices` | _type_ | registry result: the mount-ordered `ServiceDef[]` carrying the same services under `.byKey` |
|
|
6017
6367
|
| `RegistryHandlers` | _type_ | exact backend handler registry inferred from a contract registry |
|
|
6018
6368
|
| `ExactRegistryHandlers` | _type_ | fail-first handler shape that rejects extra registry and endpoint keys |
|
|
6019
6369
|
| `staticRoute` | function | a raw route that serves a directory |
|
|
@@ -6075,6 +6425,11 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
6075
6425
|
| `AuthHook` | _type_ | the hook `createAuthHook` returns |
|
|
6076
6426
|
| `AuthHookConfig` | _type_ | config for `createAuthHook` |
|
|
6077
6427
|
| `AuthRule` | _type_ | `'public' \| 'authenticated' \| predicate` |
|
|
6428
|
+
| `ScopedAuthRule` | _type_ | a rule plus its typed context contribution — `{ rule, inject? }` |
|
|
6429
|
+
| `AuthRules` | _type_ | the `rules` map: bare rules or scoped rules |
|
|
6430
|
+
| `RuleScopes` | _type_ | scope→context map derived from a `rules` object; `'public'` fields become optional |
|
|
6431
|
+
| `ScopedAuthHook` | _type_ | an auth hook carrying its derived scope map at the type level |
|
|
6432
|
+
| `AuthScopes` | _type_ | recover the derived map — `createScopedImplement<AuthScopes<typeof hook>>()` |
|
|
6078
6433
|
| `BearerResolverConfig` | _type_ | config for `createBearerResolver` |
|
|
6079
6434
|
| `JwtPayload` | _type_ | a decoded JWT payload |
|
|
6080
6435
|
| `SignJwtOptions` | _type_ | options for `signJwt` (expiry, claims) |
|
|
@@ -6125,10 +6480,19 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
6125
6480
|
| `MultipartResult` | _type_ | what `parseMultipart` returns |
|
|
6126
6481
|
| `parseMultipart` | function | parse a typed buffered/streaming multipart descriptor — [guide](../guide/server.md#multipart) |
|
|
6127
6482
|
| `defineMultipartStream` | function | bind typed streaming file receivers and a final endpoint handler |
|
|
6483
|
+
| `createMultipartStream` | function | fix one handler context type for streaming multipart endpoints |
|
|
6484
|
+
| `MultipartStreamConfig` | _type_ | receivers plus the context-typed handler accepted by the streaming builders |
|
|
6128
6485
|
| `MultipartFileMetadata` | _type_ | field, filename, declared media type and optional declared size |
|
|
6129
6486
|
| `MultipartReceiver` | _type_ | consumer-owned Web-stream storage receiver |
|
|
6130
6487
|
| `MultipartReceiverResult` | _type_ | receiver value plus rollback cleanup |
|
|
6131
6488
|
| `StreamingMultipartImplementation` | _type_ | receiver registry and handler shape inferred by `defineMultipartStream` |
|
|
6489
|
+
| `bindProcessSignals` | function | bind `SIGINT` / `SIGTERM` to one managed `shutdown()` — one chain, force on a later signal, default disposition on the one after — [guide](../guide/testing-and-deployment.md#process-signals--bindprocesssignals) |
|
|
6490
|
+
| `ProcessSignalsOptions` | _type_ | config for `bindProcessSignals` |
|
|
6491
|
+
| `ProcessSignalsBinding` | _type_ | the `{ promise, close }` handle `bindProcessSignals` returns |
|
|
6492
|
+
| `ProcessSignalsErrorPhase` | _type_ | `'prepare' \| 'shutdown' \| 'complete'` — which phase an `onError` report came from |
|
|
6493
|
+
| `SignalSource` | _type_ | injectable signal source — `process` by default |
|
|
6494
|
+
| `ProcessSignalName` | _type_ | signal names the binding accepts (owned, so the published types need no `@types/node`) |
|
|
6495
|
+
| `ShutdownTarget` | _type_ | the `shutdown`-only slice a binding needs — an interface, so a [composite target](../guide/testing-and-deployment.md#composite-shutdown-target--parallel-domain-drains) can drain several domains under one signal machine |
|
|
6132
6496
|
| `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
|
|
6133
6497
|
| `createCache` | function | an in-memory TTL cache |
|
|
6134
6498
|
| `CacheOptions` | _type_ | bounded-cache options, including the maximum retained entry count |
|
|
@@ -6257,6 +6621,7 @@ payload.
|
|
|
6257
6621
|
| `resolveMedia` | function | resolve a media reference for a tool result |
|
|
6258
6622
|
| `validateMcpSchemas` | function | object-shaped assertion over the exact advertised schema surface — compatibility, typed properties and portable formats ([guide](../guide/mcp-and-agents.md#mcp-schema-validation-profile)) |
|
|
6259
6623
|
| `listToolNames` | function | every contract/runtime tool name with origin, identity and transports — for stable snapshots — [guide](../guide/mcp-and-agents.md#pinning-tool-names--listtoolnames) |
|
|
6624
|
+
| `listContractToolNames` | function | the same listing straight from contracts — no handlers or stub services needed |
|
|
6260
6625
|
| `McpHandlerConfig` | _type_ | server surface plus stateless HTTP transport config |
|
|
6261
6626
|
| `McpHttpConfig` | _type_ | HTTP auth, protected-resource, legacy-era and security options |
|
|
6262
6627
|
| `McpHttpHandler` | _type_ | framework-owned `{ fetch(request), close() }` lifecycle |
|
|
@@ -6434,7 +6799,7 @@ runtime-agnostic pieces of `stitchkit/server` and the error helpers.
|
|
|
6434
6799
|
| `serveNode` | function | build the router and start a Node HTTP server (via `srvx`) |
|
|
6435
6800
|
| `createHandler` | function | the router as a bare `(req) => Response` (same as `/server`) |
|
|
6436
6801
|
| `createSocketIOServer` | function | the typed Node Socket.IO server (`io` + `attach`; no Bun engine declarations) |
|
|
6437
|
-
| `implement` / `createImplement` | function | bind a contract to typed handlers (same as `/server`) |
|
|
6802
|
+
| `implement` / `createImplement` / `createScopedImplement` / `createScopedImplementRegistry` / `createMultipartStream` | function | bind a contract to typed handlers, optionally typed per endpoint scope (same as `/server`) |
|
|
6438
6803
|
| `NodeServerConfig` | _type_ | config for `serveNode` |
|
|
6439
6804
|
| `NodeServerHandle` | _type_ | managed Node handle (`url`, `port`, `runtime`, `status`, `shutdown`) |
|
|
6440
6805
|
| `NodeRuntimeServer` | _type_ | concrete `srvx/node` runtime escape hatch |
|
package/package.json
CHANGED