@ryanfw/prompt-orchestration-pipeline 1.2.10 → 1.2.11

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": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.2.10",
3
+ "version": "1.2.11",
4
4
  "description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
5
5
  "type": "module",
6
6
  "main": "src/ui/server/index.ts",
@@ -1,9 +1,21 @@
1
+ import { unzipSync, zipSync } from "fflate";
1
2
  import { describe, expect, it } from "vitest";
2
3
 
3
4
  import { parseMultipartFormData, readRawBody, sendJson } from "../utils/http-utils";
4
5
  import { getMimeType, isTextMime } from "../utils/mime-types";
5
6
  import { ensureUniqueSlug, generateSlug } from "../utils/slug";
6
7
 
8
+ function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
9
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
10
+ const out = new Uint8Array(total);
11
+ let offset = 0;
12
+ for (const part of parts) {
13
+ out.set(part, offset);
14
+ offset += part.length;
15
+ }
16
+ return out;
17
+ }
18
+
7
19
  describe("server utils", () => {
8
20
  it("sends json responses", async () => {
9
21
  const response = sendJson(200, { ok: true });
@@ -46,6 +58,122 @@ describe("server utils", () => {
46
58
  expect(parsed.files[0]).toMatchObject({ filename: "seed.json", contentType: "application/json" });
47
59
  });
48
60
 
61
+ it("preserves binary zip payloads byte-for-byte", async () => {
62
+ const seed = { name: "binary-seed" };
63
+ const binary = new Uint8Array(256);
64
+ for (let i = 0; i < 256; i++) {
65
+ binary[i] = i;
66
+ }
67
+ const zip = zipSync({
68
+ "seed.json": new TextEncoder().encode(JSON.stringify(seed)),
69
+ "data.bin": binary,
70
+ });
71
+
72
+ const boundary = "binaryboundary";
73
+ const encoder = new TextEncoder();
74
+ const header = encoder.encode(
75
+ `--${boundary}\r\n` +
76
+ 'Content-Disposition: form-data; name="file"; filename="bundle.zip"\r\n' +
77
+ "Content-Type: application/zip\r\n\r\n",
78
+ );
79
+ const footer = encoder.encode(`\r\n--${boundary}--\r\n`);
80
+ const body = concatBytes([header, zip, footer]);
81
+
82
+ const request = new Request("http://localhost", {
83
+ method: "POST",
84
+ headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
85
+ body,
86
+ });
87
+
88
+ const parsed = await parseMultipartFormData(request);
89
+
90
+ // AC1: byte-for-byte identical recovery.
91
+ const file = parsed.files[0]!;
92
+ expect(file.content.length).toBe(zip.length);
93
+ expect(Array.from(file.content)).toEqual(Array.from(zip));
94
+
95
+ // AC2: recovered bytes unzip and include seed.json.
96
+ const extracted = unzipSync(file.content);
97
+ expect(Object.keys(extracted)).toContain("seed.json");
98
+ });
99
+
100
+ it("routes string fields and preserves text file metadata", async () => {
101
+ const boundary = "mixedboundary";
102
+ const body = [
103
+ `--${boundary}`,
104
+ 'Content-Disposition: form-data; name="meta"',
105
+ "",
106
+ "value",
107
+ `--${boundary}`,
108
+ 'Content-Disposition: form-data; name="file"; filename="seed.json"',
109
+ "Content-Type: application/json",
110
+ "",
111
+ '{"ok":true}',
112
+ `--${boundary}--`,
113
+ "",
114
+ ].join("\r\n");
115
+ const request = new Request("http://localhost", {
116
+ method: "POST",
117
+ headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
118
+ body,
119
+ });
120
+
121
+ const parsed = await parseMultipartFormData(request);
122
+
123
+ // AC4: a part without a filename is returned under fields.
124
+ expect(parsed.fields).toEqual({ meta: "value" });
125
+
126
+ // AC5: a .json file part preserves filename and contentType.
127
+ expect(parsed.files[0]).toMatchObject({
128
+ filename: "seed.json",
129
+ contentType: "application/json",
130
+ });
131
+ });
132
+
133
+ it("rejects non-multipart content types", async () => {
134
+ const request = new Request("http://localhost", {
135
+ method: "POST",
136
+ headers: { "content-type": "application/json" },
137
+ body: '{"ok":true}',
138
+ });
139
+
140
+ // AC6: non-multipart content type throws.
141
+ await expect(parseMultipartFormData(request)).rejects.toThrow(/multipart/);
142
+ });
143
+
144
+ it("rejects multipart content types without a boundary", async () => {
145
+ const request = new Request("http://localhost", {
146
+ method: "POST",
147
+ headers: { "content-type": "multipart/form-data" },
148
+ body: "irrelevant",
149
+ });
150
+
151
+ await expect(parseMultipartFormData(request)).rejects.toThrow(/boundary/);
152
+ });
153
+
154
+ it("enforces the multipart byte cap", async () => {
155
+ const boundary = "capboundary";
156
+ const encoder = new TextEncoder();
157
+ const body = concatBytes([
158
+ encoder.encode(
159
+ `--${boundary}\r\n` +
160
+ 'Content-Disposition: form-data; name="file"; filename="big.bin"\r\n' +
161
+ "Content-Type: application/octet-stream\r\n\r\n",
162
+ ),
163
+ new Uint8Array(1024),
164
+ encoder.encode(`\r\n--${boundary}--\r\n`),
165
+ ]);
166
+
167
+ const request = new Request("http://localhost", {
168
+ method: "POST",
169
+ headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
170
+ body,
171
+ });
172
+
173
+ // AC7: a body larger than the passed maxBytes throws.
174
+ await expect(parseMultipartFormData(request, 64)).rejects.toThrow(/exceeds/);
175
+ });
176
+
49
177
  it("maps mime types and text classification", () => {
50
178
  expect(getMimeType("file.json")).toBe("application/json");
51
179
  expect(getMimeType("file.unknown")).toBe("application/octet-stream");
@@ -0,0 +1,57 @@
1
+ import { zipSync } from "fflate";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { normalizeSeedUpload } from "../upload-endpoints";
5
+
6
+ function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
7
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
8
+ const out = new Uint8Array(total);
9
+ let offset = 0;
10
+ for (const part of parts) {
11
+ out.set(part, offset);
12
+ offset += part.length;
13
+ }
14
+ return out;
15
+ }
16
+
17
+ describe("normalizeSeedUpload", () => {
18
+ it("extracts seed and binary artifact from a multipart zip upload", async () => {
19
+ const seed = { name: "demo", pipeline: "x" };
20
+ const artifact = new Uint8Array(256);
21
+ for (let i = 0; i < 256; i++) {
22
+ artifact[i] = i;
23
+ }
24
+ const zip = zipSync({
25
+ "seed.json": new TextEncoder().encode(JSON.stringify(seed)),
26
+ "artifacts/blob.bin": artifact,
27
+ });
28
+
29
+ const boundary = "uploadboundary";
30
+ const encoder = new TextEncoder();
31
+ const header = encoder.encode(
32
+ `--${boundary}\r\n` +
33
+ 'Content-Disposition: form-data; name="file"; filename="bundle.zip"\r\n' +
34
+ "Content-Type: application/zip\r\n\r\n",
35
+ );
36
+ const footer = encoder.encode(`\r\n--${boundary}--\r\n`);
37
+ const body = concatBytes([header, zip, footer]);
38
+
39
+ const request = new Request("http://localhost", {
40
+ method: "POST",
41
+ headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
42
+ body,
43
+ });
44
+
45
+ const result = await normalizeSeedUpload(request);
46
+
47
+ // AC3: seedObject deep-equals the original seed.
48
+ expect(result.seedObject).toEqual(seed);
49
+
50
+ // AC3: exactly one artifact, byte-identical to the source blob.
51
+ expect(result.artifacts).toHaveLength(1);
52
+ const blob = result.artifacts![0]!;
53
+ expect(blob.filename).toBe("artifacts/blob.bin");
54
+ expect(blob.content.length).toBe(artifact.length);
55
+ expect(Array.from(blob.content)).toEqual(Array.from(artifact));
56
+ });
57
+ });
@@ -20,7 +20,7 @@ export function sendJson(statusCode: number, data: unknown): Response {
20
20
  export async function readRawBody(
21
21
  req: Request,
22
22
  maxBytes: number = DEFAULT_MAX_BYTES,
23
- ): Promise<Uint8Array> {
23
+ ): Promise<Uint8Array<ArrayBuffer>> {
24
24
  const buffer = new Uint8Array(await req.arrayBuffer());
25
25
  if (buffer.byteLength > maxBytes) {
26
26
  throw badRequest(`request body exceeds ${maxBytes} bytes`);
@@ -28,63 +28,40 @@ export async function readRawBody(
28
28
  return buffer;
29
29
  }
30
30
 
31
- function parseHeaders(block: string): Record<string, string> {
32
- return Object.fromEntries(
33
- block
34
- .split("\r\n")
35
- .filter(Boolean)
36
- .flatMap((line) => {
37
- const [name, ...rest] = line.split(":");
38
- if (name === undefined) return [];
39
- return [[name.trim().toLowerCase(), rest.join(":").trim()] as const];
40
- }),
41
- );
42
- }
43
-
44
- function getBoundary(contentType: string | null): string {
45
- const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType ?? "");
46
- if (!match) throw badRequest("multipart boundary is required");
47
- return match[1] ?? match[2]!;
48
- }
49
-
50
31
  export async function parseMultipartFormData(
51
32
  req: Request,
33
+ maxBytes: number = DEFAULT_MAX_BYTES,
52
34
  ): Promise<{ fields: Record<string, string>; files: MultipartFile[] }> {
53
- const boundary = getBoundary(req.headers.get("content-type"));
54
- const body = new TextDecoder().decode(await readRawBody(req));
55
- const parts = body.split(`--${boundary}`).slice(1, -1);
56
- const fields: Record<string, string> = {};
57
- const files: MultipartFile[] = [];
58
-
59
- for (const rawPart of parts) {
60
- const part = rawPart.trimStart().replace(/\r\n$/, "");
61
- const separator = part.indexOf("\r\n\r\n");
62
- if (separator < 0) continue;
63
-
64
- const headerBlock = part.slice(0, separator);
65
- const contentBlock = part.slice(separator + 4);
66
- const headers = parseHeaders(headerBlock);
67
- const disposition = headers["content-disposition"];
68
- if (!disposition) continue;
35
+ const contentType = req.headers.get("content-type");
36
+ if (
37
+ !contentType ||
38
+ !contentType.toLowerCase().includes("multipart/form-data")
39
+ ) {
40
+ throw badRequest("expected multipart/form-data content-type");
41
+ }
42
+ if (!/boundary=/i.test(contentType)) {
43
+ throw badRequest("multipart boundary is required");
44
+ }
69
45
 
70
- const nameMatch = /name="([^"]+)"/.exec(disposition);
71
- const fieldName = nameMatch?.[1];
72
- if (!fieldName) continue;
73
- const filenameMatch = /filename="([^"]*)"/.exec(disposition);
46
+ const raw = await readRawBody(req, maxBytes); // enforces the size cap on actual bytes
47
+ const form = await new Response(raw, {
48
+ headers: { "content-type": contentType },
49
+ }).formData();
74
50
 
75
- if (!filenameMatch) {
76
- fields[fieldName] = contentBlock;
77
- continue;
51
+ const fields: Record<string, string> = {};
52
+ const files: MultipartFile[] = [];
53
+ for (const name of new Set(form.keys())) {
54
+ for (const value of form.getAll(name)) {
55
+ if (typeof value === "string") {
56
+ fields[name] = value;
57
+ } else {
58
+ files.push({
59
+ filename: value.name,
60
+ contentType: value.type || "application/octet-stream",
61
+ content: new Uint8Array(await value.arrayBuffer()),
62
+ });
63
+ }
78
64
  }
79
-
80
- const filename = filenameMatch[1];
81
- if (filename === undefined) continue;
82
- files.push({
83
- filename,
84
- contentType: headers["content-type"] ?? "application/octet-stream",
85
- content: new TextEncoder().encode(contentBlock),
86
- });
87
65
  }
88
-
89
66
  return { fields, files };
90
67
  }