@codemation/core-nodes 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/core-nodes",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -33,7 +33,7 @@
33
33
  "ai": "^6.0.168",
34
34
  "croner": "^10.0.1",
35
35
  "lucide-react": "^0.577.0",
36
- "@codemation/core": "0.10.0"
36
+ "@codemation/core": "0.10.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^25.3.5",
@@ -1,3 +1,5 @@
1
+ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
2
+
1
3
  import type { Item, NodeExecutionContext } from "@codemation/core";
2
4
  import type { RunnableNodeConfig } from "@codemation/core";
3
5
  import type { HttpBodySpec } from "./httpRequest.types";
@@ -54,23 +56,7 @@ export class HttpBodyBuilder {
54
56
  if (attachment) {
55
57
  const readResult = await ctx.binary.openReadStream(attachment);
56
58
  if (readResult) {
57
- const reader = readResult.body.getReader();
58
- const chunks: Uint8Array[] = [];
59
- let done = false;
60
- while (!done) {
61
- const result = await reader.read();
62
- done = result.done;
63
- if (result.value) {
64
- chunks.push(result.value);
65
- }
66
- }
67
- const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
68
- const merged = new Uint8Array(totalLength);
69
- let offset = 0;
70
- for (const chunk of chunks) {
71
- merged.set(chunk, offset);
72
- offset += chunk.length;
73
- }
59
+ const merged = await this.readStreamToBuffer(readResult.body);
74
60
  const blob = new Blob([merged], { type: attachment.mimeType });
75
61
  formData.append(fieldName, blob, attachment.filename ?? binaryRef);
76
62
  }
@@ -85,6 +71,48 @@ export class HttpBodyBuilder {
85
71
  };
86
72
  }
87
73
 
74
+ if (spec.kind === "binary") {
75
+ const attachment = item.binary?.[spec.slot];
76
+ if (!attachment) {
77
+ throw new Error(
78
+ `HttpRequest bodyFormat "binary": no binary attachment found at slot "${spec.slot}". ` +
79
+ `Ensure a previous node attached binary data at that slot.`,
80
+ );
81
+ }
82
+ const readResult = await ctx.binary.openReadStream(attachment);
83
+ if (!readResult) {
84
+ throw new Error(`HttpRequest bodyFormat "binary": could not open read stream for slot "${spec.slot}".`);
85
+ }
86
+ // Pass the stream straight to fetch — no buffering. fetch's BodyInit
87
+ // accepts a ReadableStream natively, so big attachments upload without
88
+ // ever materialising the full payload in memory.
89
+ return {
90
+ body: readResult.body as unknown as NonNullable<RequestInit["body"]>,
91
+ contentType: attachment.mimeType,
92
+ };
93
+ }
94
+
88
95
  return undefined;
89
96
  }
97
+
98
+ private async readStreamToBuffer(stream: NodeReadableStream<Uint8Array>): Promise<Uint8Array<ArrayBuffer>> {
99
+ const reader = stream.getReader();
100
+ const chunks: Uint8Array[] = [];
101
+ let done = false;
102
+ while (!done) {
103
+ const result = await reader.read();
104
+ done = result.done;
105
+ if (result.value) {
106
+ chunks.push(result.value);
107
+ }
108
+ }
109
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
110
+ const merged = new Uint8Array(new ArrayBuffer(totalLength));
111
+ let offset = 0;
112
+ for (const chunk of chunks) {
113
+ merged.set(chunk, offset);
114
+ offset += chunk.length;
115
+ }
116
+ return merged;
117
+ }
90
118
  }
@@ -46,7 +46,10 @@ export class HttpRequestExecutor {
46
46
 
47
47
  // Only set Content-Type from the encoded body when it is non-empty
48
48
  // (empty string = FormData will set it automatically).
49
- if (encodedBody && encodedBody.contentType) {
49
+ // Explicit headers always win — only apply the body-derived content-type
50
+ // when the caller has not already set one (case-insensitive check).
51
+ const hasExplicitContentType = Object.keys(mergedHeaders).some((k) => k.toLowerCase() === "content-type");
52
+ if (encodedBody && encodedBody.contentType && !hasExplicitContentType) {
50
53
  mergedHeaders["content-type"] = encodedBody.contentType;
51
54
  }
52
55
 
@@ -17,6 +17,16 @@ export type HttpBodySpec =
17
17
  kind: "multipart";
18
18
  fields: Readonly<Record<string, string>>;
19
19
  binaries?: Readonly<Record<string, BinaryRef>>;
20
+ }>
21
+ | Readonly<{
22
+ /**
23
+ * Send raw bytes from a binary slot as the request body.
24
+ * The binary attachment's `mimeType` is used as `Content-Type` unless
25
+ * the request `headers` map already contains `content-type`.
26
+ */
27
+ kind: "binary";
28
+ /** Key into `item.binary` to read the request body bytes from. */
29
+ slot: string;
20
30
  }>;
21
31
 
22
32
  /**
@@ -48,6 +58,15 @@ export type HttpRequestSpec = Readonly<{
48
58
  body?: HttpBodySpec;
49
59
  credential?: CredentialSession;
50
60
  download?: Readonly<{ mode: "auto" | "always" | "never"; binaryName: string }>;
61
+ /**
62
+ * When set to `"binary"`, the response body is written to a binary slot
63
+ * instead of being parsed as JSON/text. Overrides `download` mode.
64
+ */
65
+ responseFormat?: "json" | "text" | "binary";
66
+ /** Binary slot name for the response body when `responseFormat === "binary"`. Defaults to `"response"`. */
67
+ responseBinarySlot?: string;
68
+ /** Maximum allowed response size in bytes (checked against Content-Length before allocating). Defaults to 100 MiB. */
69
+ responseSizeCapBytes?: number;
51
70
  /** Execution context — needed for binary attach. */
52
71
  ctx: NodeExecutionContext<RunnableNodeConfig<unknown, unknown>>;
53
72
  }>;
@@ -66,4 +85,12 @@ export type HttpRequestResult = Readonly<{
66
85
  json?: unknown;
67
86
  text?: string;
68
87
  bodyBinaryName?: string;
88
+ /** Set when `responseFormat === "binary"`. Name of the binary slot the response body was written to. */
89
+ binarySlot?: string;
90
+ /** Set when `responseFormat === "binary"`. The MIME type of the stored response. */
91
+ contentType?: string;
92
+ /** Set when `responseFormat === "binary"`. Size in bytes of the stored response. */
93
+ size?: number;
94
+ /** Set when `responseFormat === "binary"`. Filename inferred from URL or Content-Disposition. */
95
+ filename?: string;
69
96
  }>;
@@ -32,6 +32,9 @@ export class HttpRequestNode implements RunnableNode<HttpRequest<any, any>> {
32
32
  mode: ctx.config.downloadMode,
33
33
  binaryName: ctx.config.binaryName,
34
34
  },
35
+ responseFormat: ctx.config.responseFormat,
36
+ responseBinarySlot: ctx.config.responseBinarySlot,
37
+ responseSizeCapBytes: ctx.config.responseSizeCapBytes,
35
38
  ctx: ctx as unknown as HttpRequestSpec["ctx"],
36
39
  };
37
40
 
@@ -45,6 +48,12 @@ export class HttpRequestNode implements RunnableNode<HttpRequest<any, any>> {
45
48
 
46
49
  const headers = this.readHeaders(response.headers);
47
50
  const mimeType = this.resolveMimeType(headers);
51
+
52
+ // New explicit responseFormat="binary" path — takes precedence over downloadMode.
53
+ if (ctx.config.responseFormat === "binary") {
54
+ return await this.handleBinaryResponse(response, resolvedUrl, headers, mimeType, ctx);
55
+ }
56
+
48
57
  const binaryName = ctx.config.binaryName;
49
58
  const shouldAttach = this.shouldAttachBody(ctx.config.downloadMode, mimeType);
50
59
 
@@ -64,7 +73,8 @@ export class HttpRequestNode implements RunnableNode<HttpRequest<any, any>> {
64
73
  name: binaryName,
65
74
  body: response.body
66
75
  ? (response.body as unknown as Parameters<typeof ctx.binary.attach>[0]["body"])
67
- : new Uint8Array(await response.arrayBuffer()),
76
+ : // eslint-disable-next-line codemation/no-buffer-everything -- response.body is null (e.g. 204/304); fallback path is intentional and bounded.
77
+ new Uint8Array(await response.arrayBuffer()),
68
78
  mimeType,
69
79
  filename: this.resolveFilename(resolvedUrl, headers),
70
80
  });
@@ -104,6 +114,64 @@ export class HttpRequestNode implements RunnableNode<HttpRequest<any, any>> {
104
114
  return { json: outputJson };
105
115
  }
106
116
 
117
+ private async handleBinaryResponse(
118
+ response: Response,
119
+ resolvedUrl: string,
120
+ headers: Readonly<Record<string, string>>,
121
+ mimeType: string,
122
+ ctx: NodeExecutionContext<HttpRequest<any, any>>,
123
+ ): Promise<Item> {
124
+ const slotName = ctx.config.responseBinarySlot;
125
+ const sizeCap = ctx.config.responseSizeCapBytes;
126
+
127
+ // Check Content-Length against size cap before allocating.
128
+ const contentLengthHeader = headers["content-length"];
129
+ if (contentLengthHeader) {
130
+ const declaredSize = parseInt(contentLengthHeader, 10);
131
+ if (!isNaN(declaredSize) && declaredSize > sizeCap) {
132
+ throw new Error(
133
+ `HttpRequest responseFormat "binary": response Content-Length (${declaredSize} bytes) ` +
134
+ `exceeds responseSizeCapBytes (${sizeCap} bytes).`,
135
+ );
136
+ }
137
+ }
138
+
139
+ const filename = this.resolveFilename(resolvedUrl, headers);
140
+
141
+ // Stream response.body straight into binary storage — never load the
142
+ // whole payload into memory. ctx.binary.attach accepts ReadableStream
143
+ // natively. Falls back to arrayBuffer only when response.body is null
144
+ // (rare; 204/304-style responses where the cap-check above already
145
+ // covers the meaningful size case).
146
+ const attachment = await ctx.binary.attach({
147
+ name: slotName,
148
+ body: response.body
149
+ ? (response.body as unknown as Parameters<typeof ctx.binary.attach>[0]["body"])
150
+ : // eslint-disable-next-line codemation/no-buffer-everything -- response.body is null on 204/304-style empty responses; the size-cap check above already gates large bodies, so buffering an empty payload here is bounded and unavoidable.
151
+ new Uint8Array(await response.arrayBuffer()),
152
+ mimeType,
153
+ filename,
154
+ });
155
+
156
+ const outputJson: Readonly<Record<string, unknown>> = {
157
+ url: resolvedUrl,
158
+ method: ctx.config.method,
159
+ ok: response.ok,
160
+ status: response.status,
161
+ statusText: response.statusText,
162
+ headers,
163
+ binarySlot: slotName,
164
+ contentType: mimeType,
165
+ // Reported by the binary storage adapter after streaming completes.
166
+ size: attachment.size,
167
+ ...(filename !== undefined ? { filename } : {}),
168
+ };
169
+
170
+ let outputItem: Item = { json: outputJson };
171
+ outputItem = ctx.binary.withAttachment(outputItem, slotName, attachment);
172
+ return outputItem;
173
+ }
174
+
107
175
  private async resolveCredential(
108
176
  ctx: NodeExecutionContext<HttpRequest<any, any>>,
109
177
  ): Promise<CredentialSession | undefined> {
@@ -29,6 +29,14 @@ export type HttpRequestOutputJson = Readonly<{
29
29
  json?: unknown;
30
30
  text?: string;
31
31
  bodyBinaryName?: string;
32
+ /** Set when `responseFormat === "binary"`. Name of the binary slot the response was stored in. */
33
+ binarySlot?: string;
34
+ /** Set when `responseFormat === "binary"`. MIME type of the stored response. */
35
+ contentType?: string;
36
+ /** Set when `responseFormat === "binary"`. Size in bytes of the stored response. */
37
+ size?: number;
38
+ /** Set when `responseFormat === "binary"`. Filename inferred from URL or Content-Disposition. */
39
+ filename?: string;
32
40
  }>;
33
41
 
34
42
  /**
@@ -42,6 +50,9 @@ export const HTTP_REQUEST_ACCEPTED_CREDENTIAL_TYPES: ReadonlyArray<string> = [
42
50
  oauth2ClientCredentialsType.definition.typeId,
43
51
  ] as const;
44
52
 
53
+ /** Default maximum response size for binary mode: 100 MiB. */
54
+ const DEFAULT_RESPONSE_SIZE_CAP_BYTES = 100 * 1024 * 1024;
55
+
45
56
  export class HttpRequest<
46
57
  TInputJson = Readonly<{ url?: string }>,
47
58
  TOutputJson = HttpRequestOutputJson,
@@ -77,6 +88,27 @@ export class HttpRequest<
77
88
  credentialSlot?: string;
78
89
  binaryName?: string;
79
90
  downloadMode?: HttpRequestDownloadMode;
91
+ /**
92
+ * Controls how the response body is handled.
93
+ * - `"json"` / `"text"`: existing behaviour (parse + emit on `item.json`).
94
+ * - `"binary"`: read the response as raw bytes and store via `ctx.binary.attach`.
95
+ * The output JSON contains `{ status, headers, binarySlot, contentType, size, filename }`
96
+ * but NOT the raw bytes. Use `responseBinarySlot` to name the slot (default `"response"`).
97
+ *
98
+ * When omitted, the existing `downloadMode` logic applies (backward-compatible).
99
+ */
100
+ responseFormat?: "json" | "text" | "binary";
101
+ /**
102
+ * Name of the binary slot to write the response body into when `responseFormat === "binary"`.
103
+ * Defaults to `"response"`.
104
+ */
105
+ responseBinarySlot?: string;
106
+ /**
107
+ * Maximum response size in bytes for binary mode. Checked against the `Content-Length`
108
+ * response header before allocating memory. Defaults to 100 MiB (104857600).
109
+ * Requests whose `Content-Length` exceeds this cap are rejected before the body is read.
110
+ */
111
+ responseSizeCapBytes?: number;
80
112
  id?: string;
81
113
  }> = {},
82
114
  public readonly retryPolicy: RetryPolicySpec = RetryPolicy.defaultForHttp,
@@ -102,6 +134,18 @@ export class HttpRequest<
102
134
  return this.args.downloadMode ?? "auto";
103
135
  }
104
136
 
137
+ get responseFormat(): "json" | "text" | "binary" | undefined {
138
+ return this.args.responseFormat;
139
+ }
140
+
141
+ get responseBinarySlot(): string {
142
+ return this.args.responseBinarySlot ?? "response";
143
+ }
144
+
145
+ get responseSizeCapBytes(): number {
146
+ return this.args.responseSizeCapBytes ?? DEFAULT_RESPONSE_SIZE_CAP_BYTES;
147
+ }
148
+
105
149
  getCredentialRequirements(): ReadonlyArray<CredentialRequirement> {
106
150
  if (!this.args.credentialSlot) {
107
151
  return [];