@pylonsync/functions 0.20.0 → 0.21.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/define.d.ts CHANGED
@@ -44,7 +44,8 @@ interface CommonDef<TSchema extends ValidatorSchema | undefined = ValidatorSchem
44
44
  * IDLE-timeout SECONDS for this function: how long it may go without
45
45
  * producing any activity (a stream chunk, a `ctx.db` op, an LLM
46
46
  * event) before the host cancels the call. Defaults to
47
- * `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
47
+ * `PYLON_FN_CALL_TIMEOUT` (30s; Stack0 Cloud sets 300s). Activity
48
+ * restarts the budget, so a
48
49
  * streaming agent run stays alive as long as it keeps producing; a
49
50
  * silent hang is cancelled at the budget. Total lifetime is capped at
50
51
  * 10× this value however chatty the call is.
package/dist/index.d.ts CHANGED
@@ -20,10 +20,11 @@
20
20
  export { query, mutation, action } from "./define";
21
21
  export { v } from "./validators";
22
22
  export { workflow } from "./workflows";
23
+ export type { RawResponse, RawResponseInit } from "./response";
23
24
  export { agent, isAgentDefinition, validatorToJsonSchema, validatorSchemaToJsonSchema, } from "./agent";
24
25
  export type { AgentDefinition, AgentTool, AgentCallArgs, AgentResult, } from "./agent";
25
26
  export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunnerResponse, WorkflowStepResult, } from "./workflows";
26
27
  export { resetDb, installTestIsolation } from "./testing";
27
28
  export { slugifyName, availableSlug } from "./slugify";
28
29
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
29
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
30
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Raw HTTP responses from webhook actions.
3
+ *
4
+ * An action called through `/api/webhooks/<name>` normally answers with its
5
+ * return value as JSON and status 200. Returning `ctx.response(...)` sends
6
+ * the given status, content type, headers, and body instead. Every other
7
+ * route (`/api/fn/<name>`, `ctx.runAction`) returns the value as ordinary
8
+ * JSON data.
9
+ *
10
+ * The host enforces the same rules (crates/router/src/raw_response.rs), so
11
+ * a hand-built object cannot bypass them. The two header lists must stay in
12
+ * sync.
13
+ */
14
+ /** What `ctx.response` accepts. */
15
+ export interface RawResponseInit {
16
+ /** HTTP status, 200-599. Default 200. */
17
+ status?: number;
18
+ /** Shorthand for the `Content-Type` header. Default `text/plain; charset=utf-8`. */
19
+ contentType?: string;
20
+ /** Extra response headers. Values must be visible ASCII. */
21
+ headers?: Record<string, string>;
22
+ /** Response body. Default empty. Must be empty for 204, 205, and 304. */
23
+ body?: string;
24
+ }
25
+ /** The marked value an action returns. Build it with `ctx.response`. */
26
+ export interface RawResponse {
27
+ readonly __pylonResponse: 1;
28
+ readonly status: number;
29
+ readonly headers: Readonly<Record<string, string>>;
30
+ readonly body: string;
31
+ }
32
+ /**
33
+ * Build a raw HTTP response for a webhook action to return.
34
+ *
35
+ * ```ts
36
+ * return ctx.response({ contentType: "text/xml", body: "<Response/>" });
37
+ * ```
38
+ */
39
+ export declare function response(init?: RawResponseInit): RawResponse;
package/dist/runtime.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
15
15
  * needs to queue multiple RPCs per call_id.
16
16
  */
17
- import type { DbReader, DbWriter, Llm, Rooms, Workflows } from "./types";
17
+ import type { DbReader, DbWriter, Llm, Rooms, Workflows, Audit } from "./types";
18
18
  export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
19
  export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
20
20
  export declare function buildDbWriter(callId: string): DbWriter;
@@ -46,4 +46,6 @@ export declare function buildRooms(callId: string): Rooms;
46
46
  * takes it from there, so `start` returns the instance id immediately.
47
47
  * Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
48
48
  */
49
+ /** Build `ctx.audit` — round-trips `audit_op` frames to the host. */
50
+ export declare function buildAudit(callId: string): Audit;
49
51
  export declare function buildWorkflows(callId: string): Workflows;
@@ -337,6 +337,13 @@ export declare function moduleExistsIn(fs: any, path: any, cwd: string, dir: str
337
337
  * in the process.
338
338
  */
339
339
  export declare function findBoundaryIn(fs: any, path: any, cwd: string, componentPath: string, fileName: string): string | null;
340
+ /** Loopback host? `host` is a lowercase authority (`host[:port]`,
341
+ * `[ipv6]:port`). True for exactly `localhost`, dotted 127.0.0.0/8
342
+ * without leading zeros, `::1`, or `0.0.0.0`, with an optional numeric
343
+ * port. The match is exact, so
344
+ * `localhost.example.com` is not loopback. Mirrors `is_loopback_host` in
345
+ * crates/router/src/public_url.rs. Exported for tests. */
346
+ export declare function isLoopbackHost(host: string): boolean;
340
347
  /** Pure origin resolution (exported for tests).
341
348
  *
342
349
  * SECURITY: the request `Host` (and `X-Forwarded-Proto`) is attacker-
package/dist/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Type definitions for the function system.
3
3
  */
4
+ import type { RawResponse, RawResponseInit } from "./response";
4
5
  /**
5
6
  * Declarative auth requirement for a function. The framework
6
7
  * enforces this BEFORE the handler runs — if the caller doesn't
@@ -296,8 +297,23 @@ export interface Scheduler {
296
297
  runAfter(delayMs: number, fnName: string, args: Record<string, unknown>): Promise<string>;
297
298
  /** Schedule a function to run at a specific time (Unix ms). */
298
299
  runAt(timestamp: number, fnName: string, args: Record<string, unknown>): Promise<string>;
299
- /** Cancel a previously scheduled function. */
300
- cancel(scheduleId: string): Promise<void>;
300
+ /**
301
+ * Cancel a scheduled function that has not started. Pass the id
302
+ * `runAfter` or `runAt` returned.
303
+ *
304
+ * Resolves `{ cancelled: true }` when the job will not run, and
305
+ * `{ cancelled: false }` when the id is unknown or the job already
306
+ * started or finished (a running job is not interrupted). Rejects with
307
+ * `SCHEDULE_CANCEL_FAILED` when the cancel could not be saved; the job
308
+ * then still runs.
309
+ *
310
+ * Inside a mutation, the cancel commits or rolls back with the
311
+ * mutation: if the mutation throws, the job stays scheduled. From an
312
+ * action, it takes effect at once.
313
+ */
314
+ cancel(scheduleId: string): Promise<{
315
+ cancelled: boolean;
316
+ }>;
301
317
  }
302
318
  /**
303
319
  * Transactional email transport.
@@ -523,19 +539,154 @@ export interface Workflows {
523
539
  /**
524
540
  * Start a workflow instance by name. Returns immediately with the
525
541
  * instance id — the engine's background driver executes the steps.
542
+ *
543
+ * With `key` (for example a lead id), at most one non-terminal run
544
+ * exists per workflow name and key. If one exists, no new run starts
545
+ * and its id is returned with `created: false`. A finished run frees
546
+ * the key.
526
547
  */
527
- start(name: string, input?: unknown): Promise<{
548
+ start(name: string, input?: unknown, opts?: {
549
+ key?: string;
550
+ }): Promise<{
528
551
  id: string;
552
+ created: boolean;
529
553
  }>;
530
554
  /**
531
- * Deliver an event to an instance paused on
532
- * `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
533
- * for that event.
555
+ * Send an event to a run. If the run is waiting for it
556
+ * (`wf.waitForEvent(event)`), the run resumes and `delivered` is true.
557
+ * Otherwise the event is buffered (`buffered: true`) and the run's next
558
+ * `waitForEvent(event)` consumes it. Rejects when the run has finished
559
+ * or already holds 100 buffered events. Events sent without data
560
+ * arrive as `{}`.
534
561
  */
535
562
  sendEvent(workflowId: string, event: string, data?: unknown): Promise<{
536
563
  delivered: boolean;
564
+ buffered: boolean;
565
+ }>;
566
+ /**
567
+ * Cancel a run. No further step runs. A step already in progress may
568
+ * finish, but its result is discarded. Resolves `cancelled: false` when
569
+ * the run had already finished. Rejects for an unknown id.
570
+ */
571
+ cancel(workflowId: string, opts?: {
572
+ reason?: string;
573
+ }): Promise<{
574
+ cancelled: boolean;
575
+ }>;
576
+ /** Read one run, or null if the id is unknown. */
577
+ get(workflowId: string): Promise<WorkflowRunSummary | null>;
578
+ /**
579
+ * List runs, newest first. `status: "active"` matches every
580
+ * non-terminal status. `limit` defaults to 100 (max 1000).
581
+ *
582
+ * ```ts
583
+ * // Seller texted STOP: end every cadence for the lead.
584
+ * const runs = await ctx.workflows.list({ key: leadId, status: "active" });
585
+ * for (const run of runs) {
586
+ * await ctx.workflows.cancel(run.id, { reason: "STOP" });
587
+ * }
588
+ * ```
589
+ */
590
+ list(filter?: {
591
+ name?: string;
592
+ key?: string;
593
+ status?: WorkflowRunStatus | "active";
594
+ limit?: number;
595
+ }): Promise<WorkflowRunSummary[]>;
596
+ }
597
+ /**
598
+ * The application audit log (`ctx.audit` on mutations and actions).
599
+ * Append-only, stored with the auth audit events.
600
+ */
601
+ export interface AuditLog {
602
+ /**
603
+ * Record an event. The actor and tenant come from the caller's session.
604
+ * `action` is a short dotted name (`lead.export`, `script.update`) and
605
+ * is stored as `app.<action>`. `meta` values are stored as strings
606
+ * (non-strings as JSON text); keep secrets out of it.
607
+ *
608
+ * Inside a mutation the event is written after the mutation commits and
609
+ * dropped if it rolls back. Rejects with `AUDIT_WRITE_FAILED` when an
610
+ * action's event could not be stored.
611
+ */
612
+ log(event: {
613
+ action: string;
614
+ entity?: string;
615
+ entityId?: string;
616
+ /** The user the event is about, when not the actor. */
617
+ subject?: string;
618
+ meta?: Record<string, unknown>;
619
+ }): Promise<{
620
+ id: string;
537
621
  }>;
538
622
  }
623
+ /**
624
+ * `ctx.audit` on actions: {@link AuditLog.log} plus reads. Mutations get
625
+ * only `log`: in a Postgres mutation the transaction holds a connection,
626
+ * and a read would need a second.
627
+ */
628
+ export interface Audit extends AuditLog {
629
+ /**
630
+ * Read events, newest first. A caller with a tenant reads that tenant's
631
+ * events; a caller without one reads only events it performed. Admin
632
+ * callers may pass `tenant` or read all tenants. `action` matches
633
+ * `lead.export` as `app.lead.export`; framework actions
634
+ * (`entity.update`, `retention.delete`) and auth action names match as
635
+ * given. `limit` defaults to 100 (max 1000). To page,
636
+ * pass `beforeId` set to the last event's `id`. `before` (unix seconds)
637
+ * limits by time.
638
+ */
639
+ list(filter?: {
640
+ entity?: string;
641
+ entityId?: string;
642
+ actor?: string;
643
+ action?: string;
644
+ tenant?: string;
645
+ before?: number;
646
+ beforeId?: string;
647
+ limit?: number;
648
+ }): Promise<AuditEntry[]>;
649
+ }
650
+ export interface AuditEntry {
651
+ id: string;
652
+ /** Unix seconds. */
653
+ createdAt: number;
654
+ /** `app.<action>`, `entity.insert|update|delete`, `retention.delete`, or an auth action. */
655
+ action: string;
656
+ actor: string | null;
657
+ subject: string | null;
658
+ tenant: string | null;
659
+ entity: string | null;
660
+ entityId: string | null;
661
+ ip: string | null;
662
+ success: boolean;
663
+ reason: string | null;
664
+ /** For `entity.*` events, `fields` lists the changed field names. */
665
+ meta: Record<string, string>;
666
+ }
667
+ export type WorkflowRunStatus = "pending" | "running" | "sleeping" | "waiting" | "completed" | "failed" | "cancelled";
668
+ /** A workflow run without its step history. Times are unix seconds. */
669
+ export interface WorkflowRunSummary {
670
+ id: string;
671
+ name: string;
672
+ key: string | null;
673
+ status: WorkflowRunStatus;
674
+ input: unknown;
675
+ output: unknown;
676
+ error: string | null;
677
+ cancelReason: string | null;
678
+ /** Event name the run is waiting for, when `status` is "waiting". */
679
+ waitingFor: string | null;
680
+ /** When the current wait times out, if it has a timeout. */
681
+ waitDeadline: number | null;
682
+ /** When the current sleep ends. */
683
+ wakeAt: number | null;
684
+ /** Events sent but not yet consumed by a `waitForEvent`. */
685
+ bufferedEvents: number;
686
+ createdAt: number | null;
687
+ startedAt: number | null;
688
+ completedAt: number | null;
689
+ }
539
690
  export interface LlmMessage {
540
691
  role: "user" | "assistant" | "system" | "tool";
541
692
  content: string | LlmContentBlock[];
@@ -915,6 +1066,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
915
1066
  connections: Connections;
916
1067
  /** Durable workflows: start / deliver events — see {@link Workflows}. */
917
1068
  workflows: Workflows;
1069
+ /** Application audit log (write only in mutations) — see {@link AuditLog}. */
1070
+ audit: AuditLog;
918
1071
  /** Signed file-download URLs — see {@link Files}. */
919
1072
  files: Files;
920
1073
  /** Shard tickets, reads, and inputs after the commit — see {@link Shards}. */
@@ -947,8 +1100,18 @@ export interface TenantDomainResult {
947
1100
  status: string;
948
1101
  /** Whether the hostname + TLS certificate are both active. */
949
1102
  active?: boolean;
950
- /** The single CNAME the customer points their hostname at. */
951
- cnameTarget: string;
1103
+ /**
1104
+ * True for an apex domain (`acme.com`). An apex cannot be a CNAME, so the
1105
+ * customer adds A/AAAA records pointing at the app instead.
1106
+ */
1107
+ apex?: boolean;
1108
+ /**
1109
+ * Every DNS record the customer adds, for both kinds of domain: a CNAME
1110
+ * (subdomain) or A/AAAA (apex), plus ownership and certificate records.
1111
+ */
1112
+ records?: TenantDomainRecord[];
1113
+ /** The CNAME the customer points a subdomain at; null for an apex. */
1114
+ cnameTarget: string | null;
952
1115
  /** TXT record proving domain ownership before DNS cutover (or null). */
953
1116
  ownership: TenantDomainDns | null;
954
1117
  /** TXT records that issue the DV certificate. */
@@ -956,6 +1119,13 @@ export interface TenantDomainResult {
956
1119
  /** Human-readable provisioning errors, empty when healthy. */
957
1120
  errors: string[];
958
1121
  }
1122
+ export interface TenantDomainRecord {
1123
+ type: "A" | "AAAA" | "CNAME" | "TXT";
1124
+ name: string;
1125
+ value: string;
1126
+ /** What the record is for, to show the customer. */
1127
+ purpose: string;
1128
+ }
959
1129
  /**
960
1130
  * Platform domains: attach / register / detach custom domains for your app's
961
1131
  * OWN end-customers, so each tenant can reach your app on their own hostname.
@@ -1081,6 +1251,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1081
1251
  connections: Connections;
1082
1252
  /** Durable workflows: start / deliver events — see {@link Workflows}. */
1083
1253
  workflows: Workflows;
1254
+ /** Application audit log — see {@link Audit}. */
1255
+ audit: Audit;
1084
1256
  /** Attach / register custom domains for your app's OWN end-customers
1085
1257
  * (platform domains) — see {@link Domains}. Cloud-only. */
1086
1258
  domains: Domains;
@@ -1125,12 +1297,34 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1125
1297
  * ```
1126
1298
  */
1127
1299
  request?: RequestInfo;
1300
+ /**
1301
+ * Build a raw HTTP response. When an action called through
1302
+ * `/api/webhooks/<name>` returns it, the route sends this status,
1303
+ * content type, headers, and body instead of JSON. Other routes return
1304
+ * it as ordinary JSON data. Throws on an invalid status or header.
1305
+ *
1306
+ * ```ts
1307
+ * // Twilio expects TwiML, not JSON.
1308
+ * return ctx.response({ contentType: "text/xml", body: "<Response/>" });
1309
+ * ```
1310
+ */
1311
+ response(init?: RawResponseInit): RawResponse;
1128
1312
  }
1129
1313
  /** HTTP request metadata available on an action's ctx when invoked via an
1130
1314
  * HTTP route binding. Header names are lowercased. */
1131
1315
  export interface RequestInfo {
1132
1316
  method: string;
1317
+ /** Request path with the query string, as received. */
1133
1318
  path: string;
1319
+ /**
1320
+ * The full URL the client requested: scheme, host, path, and query.
1321
+ * Behind a proxy, the host comes from `X-Forwarded-Host` or `Host`
1322
+ * only when it is trusted (loopback, the `PYLON_PUBLIC_URL` host,
1323
+ * `PYLON_CANONICAL_HOST`, a `PYLON_TRUSTED_HOSTS` entry, or one of the
1324
+ * app's domains); otherwise the `PYLON_PUBLIC_URL` origin is used. Use
1325
+ * it to verify providers that sign the whole URL, such as Twilio.
1326
+ */
1327
+ url: string;
1134
1328
  headers: Record<string, string>;
1135
1329
  rawBody: string;
1136
1330
  }
@@ -57,6 +57,7 @@ export type WorkflowRunnerResponse = {
57
57
  } | {
58
58
  action: "wait_event";
59
59
  event: string;
60
+ timeout?: string;
60
61
  } | {
61
62
  action: "complete";
62
63
  output: unknown;
@@ -80,10 +81,26 @@ export interface WorkflowRun<TInput = unknown> {
80
81
  /** Pause the workflow for a duration ("30s", "5m", "24h", "7d"). */
81
82
  sleep(duration: string): Promise<void>;
82
83
  /**
83
- * Pause until `POST /api/workflows/<id>/event` delivers this event.
84
- * Resolves with the event's data payload.
84
+ * Pause until the event arrives through `ctx.workflows.sendEvent` or
85
+ * `POST /api/workflows/<id>/event`. Resolves with the event's data
86
+ * (`{}` when it was sent without data).
87
+ *
88
+ * Events sent while the run is not waiting for them are buffered. The
89
+ * next `waitForEvent` with that name consumes the oldest buffered
90
+ * event, so an event that arrives during a step is not lost.
91
+ *
92
+ * The same event name can be awaited more than once in a run; each
93
+ * call consumes one event.
85
94
  */
86
95
  waitForEvent<T = unknown>(eventName: string): Promise<T>;
96
+ /**
97
+ * Same as above, but resolves with `null` if no event arrives within
98
+ * `timeout` ("60s", "5m", "24h", "7d"). The timeout is durable: it
99
+ * survives restarts, like `sleep`.
100
+ */
101
+ waitForEvent<T = unknown>(eventName: string, opts: {
102
+ timeout: string;
103
+ }): Promise<T | null>;
87
104
  }
88
105
  export interface WorkflowDefinition<TInput = unknown> {
89
106
  readonly __pylonWorkflow: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/define.ts CHANGED
@@ -59,7 +59,8 @@ interface CommonDef<
59
59
  * IDLE-timeout SECONDS for this function: how long it may go without
60
60
  * producing any activity (a stream chunk, a `ctx.db` op, an LLM
61
61
  * event) before the host cancels the call. Defaults to
62
- * `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
62
+ * `PYLON_FN_CALL_TIMEOUT` (30s; Stack0 Cloud sets 300s). Activity
63
+ * restarts the budget, so a
63
64
  * streaming agent run stays alive as long as it keeps producing; a
64
65
  * silent hang is cancelled at the budget. Total lifetime is capped at
65
66
  * 10× this value however chatty the call is.
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  export { query, mutation, action } from "./define";
22
22
  export { v } from "./validators";
23
23
  export { workflow } from "./workflows";
24
+ export type { RawResponse, RawResponseInit } from "./response";
24
25
  export {
25
26
  agent,
26
27
  isAgentDefinition,
@@ -80,6 +81,11 @@ export type {
80
81
  ShardTransfer,
81
82
  MemberRow,
82
83
  Workflows,
84
+ WorkflowRunSummary,
85
+ WorkflowRunStatus,
86
+ Audit,
87
+ AuditLog,
88
+ AuditEntry,
83
89
  VectorSearchQuery,
84
90
  VectorSearchResult,
85
91
  SearchResult,
@@ -99,6 +105,7 @@ export type {
99
105
  // results (the DNS the end-customer must set).
100
106
  Domains,
101
107
  TenantDomainResult,
108
+ TenantDomainRecord,
102
109
  TenantDomainDns,
103
110
  DomainAvailability,
104
111
  DomainContact,
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { response } from "./response";
3
+
4
+ describe("ctx.response", () => {
5
+ test("defaults to an empty 200", () => {
6
+ expect(response()).toEqual({ __pylonResponse: 1, status: 200, headers: {}, body: "" });
7
+ });
8
+
9
+ test("contentType sets the content-type header and replaces one in headers", () => {
10
+ const r = response({
11
+ contentType: "text/xml",
12
+ headers: { "Content-Type": "text/plain", "X-Id": "1" },
13
+ body: "<Response/>",
14
+ });
15
+ expect(r.headers).toEqual({ "X-Id": "1", "content-type": "text/xml" });
16
+ expect(r.body).toBe("<Response/>");
17
+ });
18
+
19
+ test("rejects out-of-range and non-integer status", () => {
20
+ for (const status of [99, 101, 600, 200.5, Number.NaN]) {
21
+ expect(() => response({ status })).toThrow("status");
22
+ }
23
+ });
24
+
25
+ test("rejects a body on 204", () => {
26
+ expect(() => response({ status: 204, body: "x" })).toThrow("cannot carry a body");
27
+ expect(response({ status: 204 }).status).toBe(204);
28
+ });
29
+
30
+ test("rejects header injection and invalid names", () => {
31
+ expect(() => response({ headers: { "X-A": "a\r\nSet-Cookie: x=1" } })).toThrow("control");
32
+ expect(() => response({ headers: { "X-A": "café" } })).toThrow("control");
33
+ expect(() => response({ headers: { "X A": "v" } })).toThrow("token");
34
+ expect(() => response({ contentType: "text/xml\r\nX: y" })).toThrow("control");
35
+ });
36
+
37
+ test("rejects headers the server owns, in any case", () => {
38
+ for (const name of ["Content-Length", "set-cookie", "Access-Control-Allow-Origin", "Connection"]) {
39
+ expect(() => response({ headers: { [name]: "v" } })).toThrow("set by the server");
40
+ }
41
+ });
42
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Raw HTTP responses from webhook actions.
3
+ *
4
+ * An action called through `/api/webhooks/<name>` normally answers with its
5
+ * return value as JSON and status 200. Returning `ctx.response(...)` sends
6
+ * the given status, content type, headers, and body instead. Every other
7
+ * route (`/api/fn/<name>`, `ctx.runAction`) returns the value as ordinary
8
+ * JSON data.
9
+ *
10
+ * The host enforces the same rules (crates/router/src/raw_response.rs), so
11
+ * a hand-built object cannot bypass them. The two header lists must stay in
12
+ * sync.
13
+ */
14
+
15
+ /** What `ctx.response` accepts. */
16
+ export interface RawResponseInit {
17
+ /** HTTP status, 200-599. Default 200. */
18
+ status?: number;
19
+ /** Shorthand for the `Content-Type` header. Default `text/plain; charset=utf-8`. */
20
+ contentType?: string;
21
+ /** Extra response headers. Values must be visible ASCII. */
22
+ headers?: Record<string, string>;
23
+ /** Response body. Default empty. Must be empty for 204, 205, and 304. */
24
+ body?: string;
25
+ }
26
+
27
+ /** The marked value an action returns. Build it with `ctx.response`. */
28
+ export interface RawResponse {
29
+ readonly __pylonResponse: 1;
30
+ readonly status: number;
31
+ readonly headers: Readonly<Record<string, string>>;
32
+ readonly body: string;
33
+ }
34
+
35
+ /** Header names the server sets itself. Compared lowercase. */
36
+ const RESERVED_HEADERS = new Set([
37
+ "connection",
38
+ "content-length",
39
+ "keep-alive",
40
+ "proxy-authenticate",
41
+ "proxy-authorization",
42
+ "proxy-connection",
43
+ "te",
44
+ "trailer",
45
+ "transfer-encoding",
46
+ "upgrade",
47
+ "set-cookie",
48
+ "permissions-policy",
49
+ "referrer-policy",
50
+ "x-content-type-options",
51
+ "x-frame-options",
52
+ "x-xss-protection",
53
+ ]);
54
+ const RESERVED_HEADER_PREFIXES = ["access-control-"];
55
+
56
+ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
57
+ const VALUE_RE = /^[\t\x20-\x7e]*$/;
58
+
59
+ function checkHeader(name: string, value: unknown): void {
60
+ if (!TOKEN_RE.test(name)) {
61
+ throw new Error(`ctx.response: header name ${JSON.stringify(name)} is not a valid HTTP token`);
62
+ }
63
+ if (typeof value !== "string") {
64
+ throw new Error(`ctx.response: header "${name}" must have a string value`);
65
+ }
66
+ if (!VALUE_RE.test(value)) {
67
+ throw new Error(
68
+ `ctx.response: header "${name}" has a value with control or non-ASCII characters`,
69
+ );
70
+ }
71
+ const lower = name.toLowerCase();
72
+ if (RESERVED_HEADERS.has(lower) || RESERVED_HEADER_PREFIXES.some((p) => lower.startsWith(p))) {
73
+ throw new Error(`ctx.response: header "${name}" is set by the server`);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Build a raw HTTP response for a webhook action to return.
79
+ *
80
+ * ```ts
81
+ * return ctx.response({ contentType: "text/xml", body: "<Response/>" });
82
+ * ```
83
+ */
84
+ export function response(init: RawResponseInit = {}): RawResponse {
85
+ const status = init.status ?? 200;
86
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
87
+ throw new Error(`ctx.response: status must be an integer from 200 to 599, got ${status}`);
88
+ }
89
+ const body = init.body ?? "";
90
+ if (typeof body !== "string") {
91
+ throw new Error("ctx.response: body must be a string");
92
+ }
93
+ if (body !== "" && (status === 204 || status === 205 || status === 304)) {
94
+ throw new Error(`ctx.response: status ${status} cannot carry a body`);
95
+ }
96
+ const headers: Record<string, string> = {};
97
+ for (const [name, value] of Object.entries(init.headers ?? {})) {
98
+ checkHeader(name, value);
99
+ headers[name] = value;
100
+ }
101
+ if (init.contentType !== undefined) {
102
+ checkHeader("Content-Type", init.contentType);
103
+ for (const name of Object.keys(headers)) {
104
+ if (name.toLowerCase() === "content-type") delete headers[name];
105
+ }
106
+ headers["content-type"] = init.contentType;
107
+ }
108
+ return { __pylonResponse: 1, status, headers, body };
109
+ }