@webiny/app 6.6.0-alpha.0 → 6.6.0-alpha.2
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/exports/admin.d.ts +1 -0
- package/exports/admin.js +1 -0
- package/features/apiStreamClient/FetchApiStreamClient.d.ts +9 -0
- package/features/apiStreamClient/FetchApiStreamClient.js +63 -0
- package/features/apiStreamClient/FetchApiStreamClient.js.map +1 -0
- package/features/apiStreamClient/__tests__/FetchApiStreamClient.test.d.ts +1 -0
- package/features/apiStreamClient/__tests__/FetchApiStreamClient.test.js +170 -0
- package/features/apiStreamClient/__tests__/FetchApiStreamClient.test.js.map +1 -0
- package/features/apiStreamClient/__tests__/readServerSentEvents.test.d.ts +1 -0
- package/features/apiStreamClient/__tests__/readServerSentEvents.test.js +160 -0
- package/features/apiStreamClient/__tests__/readServerSentEvents.test.js.map +1 -0
- package/features/apiStreamClient/abstractions.d.ts +44 -0
- package/features/apiStreamClient/abstractions.js +10 -0
- package/features/apiStreamClient/abstractions.js.map +1 -0
- package/features/apiStreamClient/feature.d.ts +3 -0
- package/features/apiStreamClient/feature.js +17 -0
- package/features/apiStreamClient/feature.js.map +1 -0
- package/features/apiStreamClient/index.d.ts +5 -0
- package/features/apiStreamClient/index.js +5 -0
- package/features/apiStreamClient/readServerSentEvents.d.ts +10 -0
- package/features/apiStreamClient/readServerSentEvents.js +29 -0
- package/features/apiStreamClient/readServerSentEvents.js.map +1 -0
- package/features/apiStreamClient/toPayloadHash.d.ts +14 -0
- package/features/apiStreamClient/toPayloadHash.js +7 -0
- package/features/apiStreamClient/toPayloadHash.js.map +1 -0
- package/package.json +12 -12
package/exports/admin.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { MainGraphQLClient } from "../features/mainGraphQLClient/index.js";
|
|
2
|
+
export { ApiStreamClient, readServerSentEvents } from "../features/apiStreamClient/index.js";
|
|
2
3
|
export { useFeature } from "../shared/di/useFeature.js";
|
|
3
4
|
export { NetworkErrorEventHandler } from "../errors/index.js";
|
|
4
5
|
export { createProviderPlugin } from "../core/createProviderPlugin.js";
|
package/exports/admin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { MainGraphQLClient } from "../features/mainGraphQLClient/index.js";
|
|
2
|
+
export { ApiStreamClient, readServerSentEvents } from "../features/apiStreamClient/index.js";
|
|
2
3
|
export { useFeature } from "../shared/di/useFeature.js";
|
|
3
4
|
export { NetworkErrorEventHandler } from "../errors/index.js";
|
|
4
5
|
export { createProviderPlugin } from "../core/createProviderPlugin.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ApiStreamClient } from "./abstractions.js";
|
|
2
|
+
import { EnvConfig } from "../../features/envConfig/index.js";
|
|
3
|
+
declare class ApiStreamClientImpl implements ApiStreamClient.Interface {
|
|
4
|
+
private readonly apiUrl;
|
|
5
|
+
constructor(envConfig: EnvConfig.Interface);
|
|
6
|
+
execute(params: ApiStreamClient.Request): Promise<ApiStreamClient.Response>;
|
|
7
|
+
}
|
|
8
|
+
export declare const FetchApiStreamClient: import("@webiny/di").Implementation<typeof ApiStreamClientImpl>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createImplementation } from "@webiny/di";
|
|
2
|
+
import { ApiStreamClient, ApiStreamRequestError } from "./abstractions.js";
|
|
3
|
+
import { EnvConfig } from "../envConfig/index.js";
|
|
4
|
+
import { toPayloadHash } from "./toPayloadHash.js";
|
|
5
|
+
function toFetchHeaders(headers = {}) {
|
|
6
|
+
const result = {};
|
|
7
|
+
for (const [key, value] of Object.entries(headers))if (void 0 !== value) result[key] = String(value);
|
|
8
|
+
return result;
|
|
9
|
+
}
|
|
10
|
+
function joinUrl(base, path) {
|
|
11
|
+
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
12
|
+
}
|
|
13
|
+
async function toRequestError(response) {
|
|
14
|
+
let message = `Request failed with status ${response.status}.`;
|
|
15
|
+
let code;
|
|
16
|
+
try {
|
|
17
|
+
const json = await response.json();
|
|
18
|
+
if (json?.message) message = json.message;
|
|
19
|
+
if (json?.code) code = json.code;
|
|
20
|
+
} catch {}
|
|
21
|
+
return new ApiStreamRequestError(message, response.status, code);
|
|
22
|
+
}
|
|
23
|
+
class ApiStreamClientImpl {
|
|
24
|
+
constructor(envConfig){
|
|
25
|
+
this.apiUrl = envConfig.get("apiUrl");
|
|
26
|
+
}
|
|
27
|
+
async execute(params) {
|
|
28
|
+
const hasBody = void 0 !== params.body;
|
|
29
|
+
const headers = {
|
|
30
|
+
accept: "text/event-stream"
|
|
31
|
+
};
|
|
32
|
+
if (hasBody) headers["content-type"] = "application/json";
|
|
33
|
+
Object.assign(headers, toFetchHeaders(params.headers));
|
|
34
|
+
const body = hasBody ? JSON.stringify(params.body) : void 0;
|
|
35
|
+
const url = joinUrl(this.apiUrl, params.path);
|
|
36
|
+
if (void 0 !== body) headers["x-amz-content-sha256"] = await toPayloadHash(body);
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetch(url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers,
|
|
42
|
+
body,
|
|
43
|
+
signal: params.signal
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err instanceof DOMException && "AbortError" === err.name) throw err;
|
|
47
|
+
throw new Error(`Network error: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
if (!response.ok) throw await toRequestError(response);
|
|
50
|
+
if (!response.body) throw new ApiStreamRequestError("The response carried no readable body.", response.status);
|
|
51
|
+
return response;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const FetchApiStreamClient = createImplementation({
|
|
55
|
+
abstraction: ApiStreamClient,
|
|
56
|
+
implementation: ApiStreamClientImpl,
|
|
57
|
+
dependencies: [
|
|
58
|
+
EnvConfig
|
|
59
|
+
]
|
|
60
|
+
});
|
|
61
|
+
export { FetchApiStreamClient };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=FetchApiStreamClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"features/apiStreamClient/FetchApiStreamClient.js","sources":["../../../src/features/apiStreamClient/FetchApiStreamClient.ts"],"sourcesContent":["import { createImplementation } from \"@webiny/di\";\nimport { ApiStreamClient, ApiStreamRequestError } from \"./abstractions.js\";\nimport { EnvConfig } from \"~/features/envConfig/index.js\";\nimport { toPayloadHash } from \"./toPayloadHash.js\";\n\nfunction toFetchHeaders(headers: ApiStreamClient.Headers = {}): Record<string, string> {\n const result: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) {\n result[key] = String(value);\n }\n }\n return result;\n}\n\nfunction joinUrl(base: string, path: string): string {\n return `${base.replace(/\\/+$/, \"\")}/${path.replace(/^\\/+/, \"\")}`;\n}\n\n/**\n * Streaming routes answer with a normal JSON error for anything they detect BEFORE opening the\n * stream (unknown file, no permission, bad input), which is why those arrive here as a non-2xx\n * rather than as an in-stream event.\n */\nasync function toRequestError(response: Response): Promise<ApiStreamRequestError> {\n let message = `Request failed with status ${response.status}.`;\n let code: string | undefined;\n\n try {\n const json = await response.json();\n if (json?.message) {\n message = json.message;\n }\n if (json?.code) {\n code = json.code;\n }\n } catch {\n // Non-JSON error body — keep the status-based message.\n }\n\n return new ApiStreamRequestError(message, response.status, code);\n}\n\nclass ApiStreamClientImpl implements ApiStreamClient.Interface {\n private readonly apiUrl: string;\n\n constructor(envConfig: EnvConfig.Interface) {\n // The API root, not `graphqlApiUrl` — streaming routes live beside /graphql, not under it.\n this.apiUrl = envConfig.get(\"apiUrl\");\n }\n\n async execute(params: ApiStreamClient.Request): Promise<ApiStreamClient.Response> {\n const hasBody = params.body !== undefined;\n\n const headers: Record<string, string> = { accept: \"text/event-stream\" };\n if (hasBody) {\n headers[\"content-type\"] = \"application/json\";\n }\n Object.assign(headers, toFetchHeaders(params.headers));\n\n const body = hasBody ? JSON.stringify(params.body) : undefined;\n const url = joinUrl(this.apiUrl, params.path);\n\n /*\n * CloudFront's Origin Access Control signs this request but does not hash the body — it trusts\n * this header for the payload hash, and the Function URL's authorizer checks it against the\n * body it received. Without it a POST carrying a body is rejected with\n * `InvalidSignatureException`, while a bodyless one succeeds. Harmless on transports that do\n * not sign.\n */\n if (body !== undefined) {\n headers[\"x-amz-content-sha256\"] = await toPayloadHash(body);\n }\n\n let response: Response;\n try {\n // Always POST. A streaming route is an action, and POST keeps the parameters in a body\n // rather than a cacheable URL — CloudFront caches GET/HEAD.\n response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n signal: params.signal\n });\n } catch (err) {\n // Preserve an abort: it isn't a network failure and callers need to tell them apart.\n if (err instanceof DOMException && err.name === \"AbortError\") {\n throw err;\n }\n throw new Error(`Network error: ${(err as Error).message}`);\n }\n\n if (!response.ok) {\n throw await toRequestError(response);\n }\n\n if (!response.body) {\n throw new ApiStreamRequestError(\n \"The response carried no readable body.\",\n response.status\n );\n }\n\n return response;\n }\n}\n\nexport const FetchApiStreamClient = createImplementation({\n abstraction: ApiStreamClient,\n implementation: ApiStreamClientImpl,\n dependencies: [EnvConfig]\n});\n"],"names":["toFetchHeaders","headers","result","key","value","Object","undefined","String","joinUrl","base","path","toRequestError","response","message","code","json","ApiStreamRequestError","ApiStreamClientImpl","envConfig","params","hasBody","body","JSON","url","toPayloadHash","fetch","err","DOMException","Error","FetchApiStreamClient","createImplementation","ApiStreamClient","EnvConfig"],"mappings":";;;;AAKA,SAASA,eAAeC,UAAmC,CAAC,CAAC;IACzD,MAAMC,SAAiC,CAAC;IACxC,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAO,OAAO,CAACJ,SACtC,IAAIG,AAAUE,WAAVF,OACAF,MAAM,CAACC,IAAI,GAAGI,OAAOH;IAG7B,OAAOF;AACX;AAEA,SAASM,QAAQC,IAAY,EAAEC,IAAY;IACvC,OAAO,GAAGD,KAAK,OAAO,CAAC,QAAQ,IAAI,CAAC,EAAEC,KAAK,OAAO,CAAC,QAAQ,KAAK;AACpE;AAOA,eAAeC,eAAeC,QAAkB;IAC5C,IAAIC,UAAU,CAAC,2BAA2B,EAAED,SAAS,MAAM,CAAC,CAAC,CAAC;IAC9D,IAAIE;IAEJ,IAAI;QACA,MAAMC,OAAO,MAAMH,SAAS,IAAI;QAChC,IAAIG,MAAM,SACNF,UAAUE,KAAK,OAAO;QAE1B,IAAIA,MAAM,MACND,OAAOC,KAAK,IAAI;IAExB,EAAE,OAAM,CAER;IAEA,OAAO,IAAIC,sBAAsBH,SAASD,SAAS,MAAM,EAAEE;AAC/D;AAEA,MAAMG;IAGF,YAAYC,SAA8B,CAAE;QAExC,IAAI,CAAC,MAAM,GAAGA,UAAU,GAAG,CAAC;IAChC;IAEA,MAAM,QAAQC,MAA+B,EAAqC;QAC9E,MAAMC,UAAUD,AAAgBb,WAAhBa,OAAO,IAAI;QAE3B,MAAMlB,UAAkC;YAAE,QAAQ;QAAoB;QACtE,IAAImB,SACAnB,OAAO,CAAC,eAAe,GAAG;QAE9BI,OAAO,MAAM,CAACJ,SAASD,eAAemB,OAAO,OAAO;QAEpD,MAAME,OAAOD,UAAUE,KAAK,SAAS,CAACH,OAAO,IAAI,IAAIb;QACrD,MAAMiB,MAAMf,QAAQ,IAAI,CAAC,MAAM,EAAEW,OAAO,IAAI;QAS5C,IAAIE,AAASf,WAATe,MACApB,OAAO,CAAC,uBAAuB,GAAG,MAAMuB,cAAcH;QAG1D,IAAIT;QACJ,IAAI;YAGAA,WAAW,MAAMa,MAAMF,KAAK;gBACxB,QAAQ;gBACRtB;gBACAoB;gBACA,QAAQF,OAAO,MAAM;YACzB;QACJ,EAAE,OAAOO,KAAK;YAEV,IAAIA,eAAeC,gBAAgBD,AAAa,iBAAbA,IAAI,IAAI,EACvC,MAAMA;YAEV,MAAM,IAAIE,MAAM,CAAC,eAAe,EAAGF,IAAc,OAAO,EAAE;QAC9D;QAEA,IAAI,CAACd,SAAS,EAAE,EACZ,MAAM,MAAMD,eAAeC;QAG/B,IAAI,CAACA,SAAS,IAAI,EACd,MAAM,IAAII,sBACN,0CACAJ,SAAS,MAAM;QAIvB,OAAOA;IACX;AACJ;AAEO,MAAMiB,uBAAuBC,qBAAqB;IACrD,aAAaC;IACb,gBAAgBd;IAChB,cAAc;QAACe;KAAU;AAC7B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Container } from "@webiny/di";
|
|
3
|
+
import { ApiStreamClient, ApiStreamRequestError } from "../abstractions.js";
|
|
4
|
+
import { FetchApiStreamClient } from "../FetchApiStreamClient.js";
|
|
5
|
+
import { EnvConfig } from "../../envConfig/index.js";
|
|
6
|
+
import { __webpack_require__ } from "../../../rslib-runtime.js";
|
|
7
|
+
function okResponse() {
|
|
8
|
+
return {
|
|
9
|
+
ok: true,
|
|
10
|
+
status: 200,
|
|
11
|
+
body: new ReadableStream({
|
|
12
|
+
start (controller) {
|
|
13
|
+
controller.close();
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe("FetchApiStreamClient", ()=>{
|
|
19
|
+
let container;
|
|
20
|
+
let client;
|
|
21
|
+
beforeEach(()=>{
|
|
22
|
+
container = new Container();
|
|
23
|
+
container.registerInstance(EnvConfig, {
|
|
24
|
+
get: vi.fn((key)=>"apiUrl" === key ? "https://api.example.com/" : void 0)
|
|
25
|
+
});
|
|
26
|
+
container.register(FetchApiStreamClient).inSingletonScope();
|
|
27
|
+
client = container.resolve(ApiStreamClient);
|
|
28
|
+
});
|
|
29
|
+
it("should POST to the API root joined with the path", async ()=>{
|
|
30
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
31
|
+
await client.execute({
|
|
32
|
+
path: "/stream/fm/files/abc/enrich"
|
|
33
|
+
});
|
|
34
|
+
expect(__webpack_require__.g.fetch).toHaveBeenCalledWith("https://api.example.com/stream/fm/files/abc/enrich", expect.objectContaining({
|
|
35
|
+
method: "POST"
|
|
36
|
+
}));
|
|
37
|
+
});
|
|
38
|
+
it("should send the body's payload hash so OAC signing matches", async ()=>{
|
|
39
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
40
|
+
await client.execute({
|
|
41
|
+
path: "/stream/ai/chat",
|
|
42
|
+
body: {
|
|
43
|
+
prompt: "hi"
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const init = __webpack_require__.g.fetch.mock.calls[0][1];
|
|
47
|
+
expect(init.headers["x-amz-content-sha256"]).toBe("14479f4e87d340fe0ca0d522d87a5b3a028ebb1af24fbb8d3ef4553044fc6db6");
|
|
48
|
+
});
|
|
49
|
+
it("should omit the payload hash when there is no body", async ()=>{
|
|
50
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
51
|
+
await client.execute({
|
|
52
|
+
path: "/stream/fm/files/abc/enrich"
|
|
53
|
+
});
|
|
54
|
+
const init = __webpack_require__.g.fetch.mock.calls[0][1];
|
|
55
|
+
expect(init.headers).not.toHaveProperty("x-amz-content-sha256");
|
|
56
|
+
});
|
|
57
|
+
it("should not produce a double slash when joining", async ()=>{
|
|
58
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
59
|
+
await client.execute({
|
|
60
|
+
path: "stream/thing"
|
|
61
|
+
});
|
|
62
|
+
expect(__webpack_require__.g.fetch).toHaveBeenCalledWith("https://api.example.com/stream/thing", expect.anything());
|
|
63
|
+
});
|
|
64
|
+
it("should request an event stream and stringify the body", async ()=>{
|
|
65
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
66
|
+
await client.execute({
|
|
67
|
+
path: "/stream/x",
|
|
68
|
+
body: {
|
|
69
|
+
hello: "world"
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const init = __webpack_require__.g.fetch.mock.calls[0][1];
|
|
73
|
+
expect(init.headers.accept).toBe("text/event-stream");
|
|
74
|
+
expect(init.headers["content-type"]).toBe("application/json");
|
|
75
|
+
expect(init.body).toBe(JSON.stringify({
|
|
76
|
+
hello: "world"
|
|
77
|
+
}));
|
|
78
|
+
});
|
|
79
|
+
it("should pass through caller headers and drop undefined ones", async ()=>{
|
|
80
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
81
|
+
await client.execute({
|
|
82
|
+
path: "/stream/x",
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: "Bearer t",
|
|
85
|
+
"x-tenant": "root",
|
|
86
|
+
"x-skip": void 0
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
const { headers } = __webpack_require__.g.fetch.mock.calls[0][1];
|
|
90
|
+
expect(headers.Authorization).toBe("Bearer t");
|
|
91
|
+
expect(headers["x-tenant"]).toBe("root");
|
|
92
|
+
expect("x-skip" in headers).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
it("should omit the body and its content-type when there is no body", async ()=>{
|
|
95
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
96
|
+
await client.execute({
|
|
97
|
+
path: "/stream/x"
|
|
98
|
+
});
|
|
99
|
+
const init = __webpack_require__.g.fetch.mock.calls[0][1];
|
|
100
|
+
expect(init.body).toBeUndefined();
|
|
101
|
+
expect(init.headers["content-type"]).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
it("should surface a JSON error body as ApiStreamRequestError", async ()=>{
|
|
104
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue({
|
|
105
|
+
ok: false,
|
|
106
|
+
status: 404,
|
|
107
|
+
json: async ()=>({
|
|
108
|
+
message: "File not found: abc",
|
|
109
|
+
code: "ENRICHMENT_FILE_NOT_FOUND"
|
|
110
|
+
})
|
|
111
|
+
});
|
|
112
|
+
const error = await client.execute({
|
|
113
|
+
path: "/stream/x"
|
|
114
|
+
}).catch((e)=>e);
|
|
115
|
+
expect(error).toBeInstanceOf(ApiStreamRequestError);
|
|
116
|
+
expect(error.message).toBe("File not found: abc");
|
|
117
|
+
expect(error.statusCode).toBe(404);
|
|
118
|
+
expect(error.code).toBe("ENRICHMENT_FILE_NOT_FOUND");
|
|
119
|
+
});
|
|
120
|
+
it("should fall back to a status message when the error body isn't JSON", async ()=>{
|
|
121
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue({
|
|
122
|
+
ok: false,
|
|
123
|
+
status: 502,
|
|
124
|
+
json: async ()=>{
|
|
125
|
+
throw new Error("not json");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const error = await client.execute({
|
|
129
|
+
path: "/stream/x"
|
|
130
|
+
}).catch((e)=>e);
|
|
131
|
+
expect(error).toBeInstanceOf(ApiStreamRequestError);
|
|
132
|
+
expect(error.message).toBe("Request failed with status 502.");
|
|
133
|
+
expect(error.statusCode).toBe(502);
|
|
134
|
+
});
|
|
135
|
+
it("should reject a 2xx response that carries no body", async ()=>{
|
|
136
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue({
|
|
137
|
+
ok: true,
|
|
138
|
+
status: 200,
|
|
139
|
+
body: null
|
|
140
|
+
});
|
|
141
|
+
await expect(client.execute({
|
|
142
|
+
path: "/stream/x"
|
|
143
|
+
})).rejects.toThrow("The response carried no readable body.");
|
|
144
|
+
});
|
|
145
|
+
it("should wrap a network failure", async ()=>{
|
|
146
|
+
__webpack_require__.g.fetch = vi.fn().mockRejectedValue(new Error("connection reset"));
|
|
147
|
+
await expect(client.execute({
|
|
148
|
+
path: "/stream/x"
|
|
149
|
+
})).rejects.toThrow("Network error: connection reset");
|
|
150
|
+
});
|
|
151
|
+
it("should rethrow an abort untouched", async ()=>{
|
|
152
|
+
__webpack_require__.g.fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError"));
|
|
153
|
+
const error = await client.execute({
|
|
154
|
+
path: "/stream/x"
|
|
155
|
+
}).catch((e)=>e);
|
|
156
|
+
expect(error).toBeInstanceOf(DOMException);
|
|
157
|
+
expect(error.name).toBe("AbortError");
|
|
158
|
+
});
|
|
159
|
+
it("should forward the abort signal to fetch", async ()=>{
|
|
160
|
+
__webpack_require__.g.fetch = vi.fn().mockResolvedValue(okResponse());
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
await client.execute({
|
|
163
|
+
path: "/stream/x",
|
|
164
|
+
signal: controller.signal
|
|
165
|
+
});
|
|
166
|
+
expect(__webpack_require__.g.fetch.mock.calls[0][1].signal).toBe(controller.signal);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
//# sourceMappingURL=FetchApiStreamClient.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"features/apiStreamClient/__tests__/FetchApiStreamClient.test.js","sources":["../../../../src/features/apiStreamClient/__tests__/FetchApiStreamClient.test.ts"],"sourcesContent":["import { describe, it, expect, vi, beforeEach } from \"vitest\";\nimport { Container } from \"@webiny/di\";\nimport { ApiStreamClient, ApiStreamRequestError } from \"../abstractions.js\";\nimport { FetchApiStreamClient } from \"../FetchApiStreamClient.js\";\nimport { EnvConfig } from \"~/features/envConfig/index.js\";\n\nfunction okResponse() {\n return {\n ok: true,\n status: 200,\n body: new ReadableStream<Uint8Array>({\n start(controller) {\n controller.close();\n }\n })\n };\n}\n\ndescribe(\"FetchApiStreamClient\", () => {\n let container: Container;\n let client: ApiStreamClient.Interface;\n\n beforeEach(() => {\n container = new Container();\n container.registerInstance(EnvConfig, {\n get: vi.fn((key: string) => (key === \"apiUrl\" ? \"https://api.example.com/\" : undefined))\n } as any);\n container.register(FetchApiStreamClient).inSingletonScope();\n client = container.resolve(ApiStreamClient);\n });\n\n it(\"should POST to the API root joined with the path\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n\n await client.execute({ path: \"/stream/fm/files/abc/enrich\" });\n\n expect(global.fetch).toHaveBeenCalledWith(\n \"https://api.example.com/stream/fm/files/abc/enrich\",\n expect.objectContaining({ method: \"POST\" })\n );\n });\n\n /*\n * CloudFront OAC signs the request but does not hash the body, so it takes the payload hash from\n * this header. Omit it and a POST carrying a body is rejected with InvalidSignatureException while\n * a bodyless one succeeds — a failure no existing route hit, because none sent a body.\n */\n it(\"should send the body's payload hash so OAC signing matches\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n\n await client.execute({ path: \"/stream/ai/chat\", body: { prompt: \"hi\" } });\n\n const init = (global.fetch as any).mock.calls[0][1];\n // sha256 of {\"prompt\":\"hi\"}\n expect(init.headers[\"x-amz-content-sha256\"]).toBe(\n \"14479f4e87d340fe0ca0d522d87a5b3a028ebb1af24fbb8d3ef4553044fc6db6\"\n );\n });\n\n it(\"should omit the payload hash when there is no body\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n\n await client.execute({ path: \"/stream/fm/files/abc/enrich\" });\n\n const init = (global.fetch as any).mock.calls[0][1];\n expect(init.headers).not.toHaveProperty(\"x-amz-content-sha256\");\n });\n\n it(\"should not produce a double slash when joining\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n\n await client.execute({ path: \"stream/thing\" });\n\n expect(global.fetch).toHaveBeenCalledWith(\n \"https://api.example.com/stream/thing\",\n expect.anything()\n );\n });\n\n it(\"should request an event stream and stringify the body\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n\n await client.execute({ path: \"/stream/x\", body: { hello: \"world\" } });\n\n const init = (global.fetch as any).mock.calls[0][1];\n expect(init.headers.accept).toBe(\"text/event-stream\");\n expect(init.headers[\"content-type\"]).toBe(\"application/json\");\n expect(init.body).toBe(JSON.stringify({ hello: \"world\" }));\n });\n\n it(\"should pass through caller headers and drop undefined ones\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n\n await client.execute({\n path: \"/stream/x\",\n headers: { Authorization: \"Bearer t\", \"x-tenant\": \"root\", \"x-skip\": undefined }\n });\n\n const { headers } = (global.fetch as any).mock.calls[0][1];\n expect(headers.Authorization).toBe(\"Bearer t\");\n expect(headers[\"x-tenant\"]).toBe(\"root\");\n expect(\"x-skip\" in headers).toBe(false);\n });\n\n it(\"should omit the body and its content-type when there is no body\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n\n await client.execute({ path: \"/stream/x\" });\n\n const init = (global.fetch as any).mock.calls[0][1];\n expect(init.body).toBeUndefined();\n expect(init.headers[\"content-type\"]).toBeUndefined();\n });\n\n it(\"should surface a JSON error body as ApiStreamRequestError\", async () => {\n global.fetch = vi.fn().mockResolvedValue({\n ok: false,\n status: 404,\n json: async () => ({\n message: \"File not found: abc\",\n code: \"ENRICHMENT_FILE_NOT_FOUND\"\n })\n });\n\n const error = await client.execute({ path: \"/stream/x\" }).catch(e => e);\n\n expect(error).toBeInstanceOf(ApiStreamRequestError);\n expect(error.message).toBe(\"File not found: abc\");\n expect(error.statusCode).toBe(404);\n expect(error.code).toBe(\"ENRICHMENT_FILE_NOT_FOUND\");\n });\n\n it(\"should fall back to a status message when the error body isn't JSON\", async () => {\n global.fetch = vi.fn().mockResolvedValue({\n ok: false,\n status: 502,\n json: async () => {\n throw new Error(\"not json\");\n }\n });\n\n const error = await client.execute({ path: \"/stream/x\" }).catch(e => e);\n\n expect(error).toBeInstanceOf(ApiStreamRequestError);\n expect(error.message).toBe(\"Request failed with status 502.\");\n expect(error.statusCode).toBe(502);\n });\n\n it(\"should reject a 2xx response that carries no body\", async () => {\n global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, body: null });\n\n await expect(client.execute({ path: \"/stream/x\" })).rejects.toThrow(\n \"The response carried no readable body.\"\n );\n });\n\n it(\"should wrap a network failure\", async () => {\n global.fetch = vi.fn().mockRejectedValue(new Error(\"connection reset\"));\n\n await expect(client.execute({ path: \"/stream/x\" })).rejects.toThrow(\n \"Network error: connection reset\"\n );\n });\n\n it(\"should rethrow an abort untouched\", async () => {\n // Callers distinguish \"user cancelled\" from \"request failed\"; wrapping an abort in a generic\n // network error would erase that.\n global.fetch = vi.fn().mockRejectedValue(new DOMException(\"aborted\", \"AbortError\"));\n\n const error = await client.execute({ path: \"/stream/x\" }).catch(e => e);\n\n expect(error).toBeInstanceOf(DOMException);\n expect(error.name).toBe(\"AbortError\");\n });\n\n it(\"should forward the abort signal to fetch\", async () => {\n global.fetch = vi.fn().mockResolvedValue(okResponse());\n const controller = new AbortController();\n\n await client.execute({ path: \"/stream/x\", signal: controller.signal });\n\n expect((global.fetch as any).mock.calls[0][1].signal).toBe(controller.signal);\n });\n});\n"],"names":["okResponse","ReadableStream","controller","describe","container","client","beforeEach","Container","EnvConfig","vi","key","undefined","FetchApiStreamClient","ApiStreamClient","it","global","expect","init","JSON","headers","error","e","ApiStreamRequestError","Error","DOMException","AbortController"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readServerSentEvents } from "../readServerSentEvents.js";
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
function responseFrom(chunks) {
|
|
5
|
+
const stream = new ReadableStream({
|
|
6
|
+
start (controller) {
|
|
7
|
+
for (const chunk of chunks)controller.enqueue("string" == typeof chunk ? encoder.encode(chunk) : chunk);
|
|
8
|
+
controller.close();
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
body: stream
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function collect(response) {
|
|
16
|
+
const events = [];
|
|
17
|
+
for await (const event of readServerSentEvents(response))events.push(event);
|
|
18
|
+
return events;
|
|
19
|
+
}
|
|
20
|
+
describe("readServerSentEvents", ()=>{
|
|
21
|
+
it("should parse one event per record", async ()=>{
|
|
22
|
+
const events = await collect(responseFrom([
|
|
23
|
+
'data: {"type":"start"}\n\n',
|
|
24
|
+
'data: {"type":"done"}\n\n'
|
|
25
|
+
]));
|
|
26
|
+
expect(events).toEqual([
|
|
27
|
+
{
|
|
28
|
+
type: "start"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: "done"
|
|
32
|
+
}
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
it("should parse multiple records arriving in a single chunk", async ()=>{
|
|
36
|
+
const events = await collect(responseFrom([
|
|
37
|
+
'data: {"n":1}\n\ndata: {"n":2}\n\ndata: {"n":3}\n\n'
|
|
38
|
+
]));
|
|
39
|
+
expect(events).toEqual([
|
|
40
|
+
{
|
|
41
|
+
n: 1
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
n: 2
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
n: 3
|
|
48
|
+
}
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
it("should parse a record split across chunks", async ()=>{
|
|
52
|
+
const events = await collect(responseFrom([
|
|
53
|
+
'data: {"ty',
|
|
54
|
+
'pe":"partial"}',
|
|
55
|
+
"\n\n"
|
|
56
|
+
]));
|
|
57
|
+
expect(events).toEqual([
|
|
58
|
+
{
|
|
59
|
+
type: "partial"
|
|
60
|
+
}
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
it("should handle CRLF line endings", async ()=>{
|
|
64
|
+
const events = await collect(responseFrom([
|
|
65
|
+
'data: {"ok":true}\r\n\r\n'
|
|
66
|
+
]));
|
|
67
|
+
expect(events).toEqual([
|
|
68
|
+
{
|
|
69
|
+
ok: true
|
|
70
|
+
}
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
it("should ignore comments and non-data fields", async ()=>{
|
|
74
|
+
const events = await collect(responseFrom([
|
|
75
|
+
": heartbeat\n\n",
|
|
76
|
+
'event: message\nid: 7\nretry: 500\ndata: {"kept":true}\n\n'
|
|
77
|
+
]));
|
|
78
|
+
expect(events).toEqual([
|
|
79
|
+
{
|
|
80
|
+
kept: true
|
|
81
|
+
}
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
it("should join multi-line data fields", async ()=>{
|
|
85
|
+
const events = await collect(responseFrom([
|
|
86
|
+
'data: {"a":1,\ndata: "b":2}\n\n'
|
|
87
|
+
]));
|
|
88
|
+
expect(events).toEqual([
|
|
89
|
+
{
|
|
90
|
+
a: 1,
|
|
91
|
+
b: 2
|
|
92
|
+
}
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
it("should preserve multi-byte characters split across chunks", async ()=>{
|
|
96
|
+
const payload = encoder.encode('data: {"text":"café"}\n\n');
|
|
97
|
+
const split = 18;
|
|
98
|
+
const events = await collect(responseFrom([
|
|
99
|
+
payload.slice(0, split),
|
|
100
|
+
payload.slice(split)
|
|
101
|
+
]));
|
|
102
|
+
expect(events).toEqual([
|
|
103
|
+
{
|
|
104
|
+
text: "café"
|
|
105
|
+
}
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
it("should drop a trailing record that never terminated", async ()=>{
|
|
109
|
+
const events = await collect(responseFrom([
|
|
110
|
+
'data: {"complete":true}\n\ndata: {"trunc'
|
|
111
|
+
]));
|
|
112
|
+
expect(events).toEqual([
|
|
113
|
+
{
|
|
114
|
+
complete: true
|
|
115
|
+
}
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
it("should yield events as they arrive rather than after the stream closes", async ()=>{
|
|
119
|
+
let released;
|
|
120
|
+
const gate = new Promise((resolve)=>{
|
|
121
|
+
released = resolve;
|
|
122
|
+
});
|
|
123
|
+
const stream = new ReadableStream({
|
|
124
|
+
async start (controller) {
|
|
125
|
+
controller.enqueue(encoder.encode('data: {"n":1}\n\n'));
|
|
126
|
+
await gate;
|
|
127
|
+
controller.enqueue(encoder.encode('data: {"n":2}\n\n'));
|
|
128
|
+
controller.close();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const iterator = readServerSentEvents({
|
|
132
|
+
body: stream
|
|
133
|
+
});
|
|
134
|
+
expect((await iterator.next()).value).toEqual({
|
|
135
|
+
n: 1
|
|
136
|
+
});
|
|
137
|
+
released();
|
|
138
|
+
expect((await iterator.next()).value).toEqual({
|
|
139
|
+
n: 2
|
|
140
|
+
});
|
|
141
|
+
expect((await iterator.next()).done).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
it("should throw when the response has no body", async ()=>{
|
|
144
|
+
await expect(collect({
|
|
145
|
+
body: null
|
|
146
|
+
})).rejects.toThrow("The response carried no readable body.");
|
|
147
|
+
});
|
|
148
|
+
it("should propagate a stream error", async ()=>{
|
|
149
|
+
const stream = new ReadableStream({
|
|
150
|
+
start (controller) {
|
|
151
|
+
controller.error(new Error("stream broke"));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
await expect(collect({
|
|
155
|
+
body: stream
|
|
156
|
+
})).rejects.toThrow("stream broke");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
//# sourceMappingURL=readServerSentEvents.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"features/apiStreamClient/__tests__/readServerSentEvents.test.js","sources":["../../../../src/features/apiStreamClient/__tests__/readServerSentEvents.test.ts"],"sourcesContent":["import { describe, it, expect } from \"vitest\";\nimport { readServerSentEvents } from \"../readServerSentEvents.js\";\n\nconst encoder = new TextEncoder();\n\nfunction responseFrom(chunks: (string | Uint8Array)[]): Response {\n const stream = new ReadableStream<Uint8Array>({\n start(controller) {\n for (const chunk of chunks) {\n controller.enqueue(typeof chunk === \"string\" ? encoder.encode(chunk) : chunk);\n }\n controller.close();\n }\n });\n\n return { body: stream } as Response;\n}\n\nasync function collect<T>(response: Response): Promise<T[]> {\n const events: T[] = [];\n for await (const event of readServerSentEvents<T>(response)) {\n events.push(event);\n }\n return events;\n}\n\ndescribe(\"readServerSentEvents\", () => {\n it(\"should parse one event per record\", async () => {\n const events = await collect(\n responseFrom(['data: {\"type\":\"start\"}\\n\\n', 'data: {\"type\":\"done\"}\\n\\n'])\n );\n\n expect(events).toEqual([{ type: \"start\" }, { type: \"done\" }]);\n });\n\n it(\"should parse multiple records arriving in a single chunk\", async () => {\n const events = await collect(\n responseFrom(['data: {\"n\":1}\\n\\ndata: {\"n\":2}\\n\\ndata: {\"n\":3}\\n\\n'])\n );\n\n expect(events).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);\n });\n\n it(\"should parse a record split across chunks\", async () => {\n const events = await collect(responseFrom(['data: {\"ty', 'pe\":\"partial\"}', \"\\n\\n\"]));\n\n expect(events).toEqual([{ type: \"partial\" }]);\n });\n\n it(\"should handle CRLF line endings\", async () => {\n const events = await collect(responseFrom(['data: {\"ok\":true}\\r\\n\\r\\n']));\n\n expect(events).toEqual([{ ok: true }]);\n });\n\n it(\"should ignore comments and non-data fields\", async () => {\n const events = await collect(\n responseFrom([\n \": heartbeat\\n\\n\",\n 'event: message\\nid: 7\\nretry: 500\\ndata: {\"kept\":true}\\n\\n'\n ])\n );\n\n expect(events).toEqual([{ kept: true }]);\n });\n\n it(\"should join multi-line data fields\", async () => {\n const events = await collect(responseFrom(['data: {\"a\":1,\\ndata: \"b\":2}\\n\\n']));\n\n expect(events).toEqual([{ a: 1, b: 2 }]);\n });\n\n it(\"should preserve multi-byte characters split across chunks\", async () => {\n const payload = encoder.encode('data: {\"text\":\"café\"}\\n\\n');\n const split = 18;\n\n const events = await collect(responseFrom([payload.slice(0, split), payload.slice(split)]));\n\n expect(events).toEqual([{ text: \"café\" }]);\n });\n\n it(\"should drop a trailing record that never terminated\", async () => {\n // A truncated stream (server died mid-record) must not yield a half-parsed event.\n const events = await collect(responseFrom(['data: {\"complete\":true}\\n\\ndata: {\"trunc']));\n\n expect(events).toEqual([{ complete: true }]);\n });\n\n it(\"should yield events as they arrive rather than after the stream closes\", async () => {\n let released!: () => void;\n const gate = new Promise<void>(resolve => {\n released = resolve;\n });\n\n const stream = new ReadableStream<Uint8Array>({\n async start(controller) {\n controller.enqueue(encoder.encode('data: {\"n\":1}\\n\\n'));\n await gate;\n controller.enqueue(encoder.encode('data: {\"n\":2}\\n\\n'));\n controller.close();\n }\n });\n\n const iterator = readServerSentEvents<{ n: number }>({ body: stream } as Response);\n\n // Resolving before the gate opens proves events aren't buffered until close.\n expect((await iterator.next()).value).toEqual({ n: 1 });\n released();\n expect((await iterator.next()).value).toEqual({ n: 2 });\n expect((await iterator.next()).done).toBe(true);\n });\n\n it(\"should throw when the response has no body\", async () => {\n await expect(collect({ body: null } as Response)).rejects.toThrow(\n \"The response carried no readable body.\"\n );\n });\n\n it(\"should propagate a stream error\", async () => {\n const stream = new ReadableStream<Uint8Array>({\n start(controller) {\n controller.error(new Error(\"stream broke\"));\n }\n });\n\n await expect(collect({ body: stream } as Response)).rejects.toThrow(\"stream broke\");\n });\n});\n"],"names":["encoder","TextEncoder","responseFrom","chunks","stream","ReadableStream","controller","chunk","collect","response","events","event","readServerSentEvents","describe","it","expect","payload","split","released","gate","Promise","resolve","iterator","Error"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type IHeaders = Record<string, string | number | undefined>;
|
|
2
|
+
/**
|
|
3
|
+
* The raw `fetch` response, handed to the caller unread so it owns the read loop. Aliased through
|
|
4
|
+
* the abstraction so consumers name the contract rather than the DOM type it currently happens to
|
|
5
|
+
* be — and so `ApiStreamClient.Response` reads as the counterpart to `ApiStreamClient.Request`.
|
|
6
|
+
*/
|
|
7
|
+
type IApiStreamResponse = globalThis.Response;
|
|
8
|
+
export interface IApiStreamRequest {
|
|
9
|
+
/** Path relative to the API root, e.g. `/stream/fm/files/abc/enrich`. */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Serialized as JSON when present. */
|
|
12
|
+
body?: unknown;
|
|
13
|
+
headers?: IHeaders;
|
|
14
|
+
/**
|
|
15
|
+
* Aborts the request AND the caller's read loop. Streaming responses stay open for as long as the
|
|
16
|
+
* producer runs, so without this a caller that navigates away or closes its UI would leave the
|
|
17
|
+
* connection open and keep consuming events into a component that no longer exists.
|
|
18
|
+
*/
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Client for API endpoints that stream their response.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately separate from `GraphQLClient`: that abstraction returns `Promise<TResult>` — a
|
|
25
|
+
* buffered contract by type — and Webiny's GraphQL layer (graphql-js 16) has no incremental
|
|
26
|
+
* delivery, so a streaming response can't travel through it. This returns the raw `Response` so the
|
|
27
|
+
* caller owns the read loop and can hand `response.body` to any stream consumer.
|
|
28
|
+
*/
|
|
29
|
+
export interface IApiStreamClient {
|
|
30
|
+
execute(params: IApiStreamRequest): Promise<IApiStreamResponse>;
|
|
31
|
+
}
|
|
32
|
+
export declare const ApiStreamClient: import("@webiny/di").Abstraction<IApiStreamClient>;
|
|
33
|
+
export declare namespace ApiStreamClient {
|
|
34
|
+
type Headers = IHeaders;
|
|
35
|
+
type Interface = IApiStreamClient;
|
|
36
|
+
type Request = IApiStreamRequest;
|
|
37
|
+
type Response = IApiStreamResponse;
|
|
38
|
+
}
|
|
39
|
+
export declare class ApiStreamRequestError extends Error {
|
|
40
|
+
readonly statusCode: number;
|
|
41
|
+
readonly code?: string | undefined;
|
|
42
|
+
constructor(message: string, statusCode: number, code?: string | undefined);
|
|
43
|
+
}
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createAbstraction } from "@webiny/feature/admin";
|
|
2
|
+
const ApiStreamClient = createAbstraction("ApiStreamClient");
|
|
3
|
+
class ApiStreamRequestError extends Error {
|
|
4
|
+
constructor(message, statusCode, code){
|
|
5
|
+
super(message), this.statusCode = statusCode, this.code = code;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export { ApiStreamClient, ApiStreamRequestError };
|
|
9
|
+
|
|
10
|
+
//# sourceMappingURL=abstractions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"features/apiStreamClient/abstractions.js","sources":["../../../src/features/apiStreamClient/abstractions.ts"],"sourcesContent":["import { createAbstraction } from \"@webiny/feature/admin\";\n\ntype IHeaders = Record<string, string | number | undefined>;\n\n/**\n * The raw `fetch` response, handed to the caller unread so it owns the read loop. Aliased through\n * the abstraction so consumers name the contract rather than the DOM type it currently happens to\n * be — and so `ApiStreamClient.Response` reads as the counterpart to `ApiStreamClient.Request`.\n */\ntype IApiStreamResponse = globalThis.Response;\n\nexport interface IApiStreamRequest {\n /** Path relative to the API root, e.g. `/stream/fm/files/abc/enrich`. */\n path: string;\n /** Serialized as JSON when present. */\n body?: unknown;\n headers?: IHeaders;\n /**\n * Aborts the request AND the caller's read loop. Streaming responses stay open for as long as the\n * producer runs, so without this a caller that navigates away or closes its UI would leave the\n * connection open and keep consuming events into a component that no longer exists.\n */\n signal?: AbortSignal;\n}\n\n/**\n * Client for API endpoints that stream their response.\n *\n * Deliberately separate from `GraphQLClient`: that abstraction returns `Promise<TResult>` — a\n * buffered contract by type — and Webiny's GraphQL layer (graphql-js 16) has no incremental\n * delivery, so a streaming response can't travel through it. This returns the raw `Response` so the\n * caller owns the read loop and can hand `response.body` to any stream consumer.\n */\nexport interface IApiStreamClient {\n execute(params: IApiStreamRequest): Promise<IApiStreamResponse>;\n}\n\nexport const ApiStreamClient = createAbstraction<IApiStreamClient>(\"ApiStreamClient\");\n\nexport namespace ApiStreamClient {\n export type Headers = IHeaders;\n export type Interface = IApiStreamClient;\n export type Request = IApiStreamRequest;\n export type Response = IApiStreamResponse;\n}\n\nexport class ApiStreamRequestError extends Error {\n constructor(\n message: string,\n readonly statusCode: number,\n readonly code?: string\n ) {\n super(message);\n }\n}\n"],"names":["ApiStreamClient","createAbstraction","ApiStreamRequestError","Error","message","statusCode","code"],"mappings":";AAqCO,MAAMA,kBAAkBC,kBAAoC;AAS5D,MAAMC,8BAA8BC;IACvC,YACIC,OAAe,EACNC,UAAkB,EAClBC,IAAa,CACxB;QACE,KAAK,CAACF,UAAAA,IAAAA,CAHGC,UAAU,GAAVA,YAAAA,IAAAA,CACAC,IAAI,GAAJA;IAGb;AACJ"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ApiStreamClient } from "./abstractions.js";
|
|
2
|
+
import { FetchApiStreamClient } from "./FetchApiStreamClient.js";
|
|
3
|
+
import { createFeature } from "../../shared/di/createFeature.js";
|
|
4
|
+
const ApiStreamClientFeature = createFeature({
|
|
5
|
+
name: "ApiStreamClient",
|
|
6
|
+
register (container) {
|
|
7
|
+
container.register(FetchApiStreamClient).inSingletonScope();
|
|
8
|
+
},
|
|
9
|
+
resolve (container) {
|
|
10
|
+
return {
|
|
11
|
+
client: container.resolve(ApiStreamClient)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
export { ApiStreamClientFeature };
|
|
16
|
+
|
|
17
|
+
//# sourceMappingURL=feature.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"features/apiStreamClient/feature.js","sources":["../../../src/features/apiStreamClient/feature.ts"],"sourcesContent":["import { ApiStreamClient } from \"./abstractions.js\";\nimport { FetchApiStreamClient } from \"./FetchApiStreamClient.js\";\nimport { createFeature } from \"~/shared/di/createFeature.js\";\n\nexport const ApiStreamClientFeature = createFeature({\n name: \"ApiStreamClient\",\n register(container) {\n container.register(FetchApiStreamClient).inSingletonScope();\n },\n resolve(container) {\n return {\n client: container.resolve(ApiStreamClient)\n };\n }\n});\n"],"names":["ApiStreamClientFeature","createFeature","container","FetchApiStreamClient","ApiStreamClient"],"mappings":";;;AAIO,MAAMA,yBAAyBC,cAAc;IAChD,MAAM;IACN,UAASC,SAAS;QACdA,UAAU,QAAQ,CAACC,sBAAsB,gBAAgB;IAC7D;IACA,SAAQD,SAAS;QACb,OAAO;YACH,QAAQA,UAAU,OAAO,CAACE;QAC9B;IACJ;AACJ"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a `text/event-stream` response as a sequence of parsed JSON events.
|
|
3
|
+
*
|
|
4
|
+
* Written against `Response.body` rather than `EventSource` on purpose: `EventSource` can only issue
|
|
5
|
+
* GET requests and cannot set an `Authorization` header, both of which the API requires.
|
|
6
|
+
*
|
|
7
|
+
* Only the `data:` field is interpreted — enough for Webiny's streaming routes, which frame one JSON
|
|
8
|
+
* object per record. Comment lines (`:` heartbeats), `event:`, `id:` and `retry:` are ignored.
|
|
9
|
+
*/
|
|
10
|
+
export declare function readServerSentEvents<TEvent>(response: Response): AsyncGenerator<TEvent>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
async function* readServerSentEvents(response) {
|
|
2
|
+
const body = response.body;
|
|
3
|
+
if (!body) throw new Error("The response carried no readable body.");
|
|
4
|
+
const reader = body.getReader();
|
|
5
|
+
const decoder = new TextDecoder();
|
|
6
|
+
let buffer = "";
|
|
7
|
+
try {
|
|
8
|
+
while(true){
|
|
9
|
+
const { done, value } = await reader.read();
|
|
10
|
+
if (done) break;
|
|
11
|
+
buffer += decoder.decode(value, {
|
|
12
|
+
stream: true
|
|
13
|
+
}).replace(/\r\n/g, "\n");
|
|
14
|
+
let separator = buffer.indexOf("\n\n");
|
|
15
|
+
while(-1 !== separator){
|
|
16
|
+
const record = buffer.slice(0, separator);
|
|
17
|
+
buffer = buffer.slice(separator + 2);
|
|
18
|
+
const data = record.split("\n").filter((line)=>line.startsWith("data:")).map((line)=>line.slice(5).trim()).join("\n");
|
|
19
|
+
if (data) yield JSON.parse(data);
|
|
20
|
+
separator = buffer.indexOf("\n\n");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} finally{
|
|
24
|
+
reader.releaseLock();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export { readServerSentEvents };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=readServerSentEvents.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"features/apiStreamClient/readServerSentEvents.js","sources":["../../../src/features/apiStreamClient/readServerSentEvents.ts"],"sourcesContent":["/**\n * Read a `text/event-stream` response as a sequence of parsed JSON events.\n *\n * Written against `Response.body` rather than `EventSource` on purpose: `EventSource` can only issue\n * GET requests and cannot set an `Authorization` header, both of which the API requires.\n *\n * Only the `data:` field is interpreted — enough for Webiny's streaming routes, which frame one JSON\n * object per record. Comment lines (`:` heartbeats), `event:`, `id:` and `retry:` are ignored.\n */\nexport async function* readServerSentEvents<TEvent>(response: Response): AsyncGenerator<TEvent> {\n const body = response.body;\n if (!body) {\n throw new Error(\"The response carried no readable body.\");\n }\n\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n\n if (done) {\n break;\n }\n\n // `stream: true` keeps a multi-byte character split across chunks intact.\n buffer += decoder.decode(value, { stream: true }).replace(/\\r\\n/g, \"\\n\");\n\n let separator = buffer.indexOf(\"\\n\\n\");\n while (separator !== -1) {\n const record = buffer.slice(0, separator);\n buffer = buffer.slice(separator + 2);\n\n const data = record\n .split(\"\\n\")\n .filter(line => line.startsWith(\"data:\"))\n .map(line => line.slice(\"data:\".length).trim())\n .join(\"\\n\");\n\n if (data) {\n yield JSON.parse(data) as TEvent;\n }\n\n separator = buffer.indexOf(\"\\n\\n\");\n }\n }\n } finally {\n reader.releaseLock();\n }\n}\n"],"names":["readServerSentEvents","response","body","Error","reader","decoder","TextDecoder","buffer","done","value","separator","record","data","line","JSON"],"mappings":"AASO,gBAAgBA,qBAA6BC,QAAkB;IAClE,MAAMC,OAAOD,SAAS,IAAI;IAC1B,IAAI,CAACC,MACD,MAAM,IAAIC,MAAM;IAGpB,MAAMC,SAASF,KAAK,SAAS;IAC7B,MAAMG,UAAU,IAAIC;IACpB,IAAIC,SAAS;IAEb,IAAI;QACA,MAAO,KAAM;YACT,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAE,GAAG,MAAML,OAAO,IAAI;YAEzC,IAAII,MACA;YAIJD,UAAUF,QAAQ,MAAM,CAACI,OAAO;gBAAE,QAAQ;YAAK,GAAG,OAAO,CAAC,SAAS;YAEnE,IAAIC,YAAYH,OAAO,OAAO,CAAC;YAC/B,MAAOG,AAAc,OAAdA,UAAkB;gBACrB,MAAMC,SAASJ,OAAO,KAAK,CAAC,GAAGG;gBAC/BH,SAASA,OAAO,KAAK,CAACG,YAAY;gBAElC,MAAME,OAAOD,OACR,KAAK,CAAC,MACN,MAAM,CAACE,CAAAA,OAAQA,KAAK,UAAU,CAAC,UAC/B,GAAG,CAACA,CAAAA,OAAQA,KAAK,KAAK,CAAC,GAAgB,IAAI,IAC3C,IAAI,CAAC;gBAEV,IAAID,MACA,MAAME,KAAK,KAAK,CAACF;gBAGrBF,YAAYH,OAAO,OAAO,CAAC;YAC/B;QACJ;IACJ,SAAU;QACNH,OAAO,WAAW;IACtB;AACJ"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hex SHA-256 of a request body, for the `x-amz-content-sha256` header.
|
|
3
|
+
*
|
|
4
|
+
* Required whenever a streaming route is reached through CloudFront with Origin Access Control. OAC
|
|
5
|
+
* signs the request with SigV4 but does NOT hash the body itself — it folds whatever this header says
|
|
6
|
+
* into the signature, and the Lambda Function URL's IAM authorizer then recomputes the hash from the
|
|
7
|
+
* body it received. Omit the header and the two disagree, so a POST WITH a body is rejected with
|
|
8
|
+
* `InvalidSignatureException` while a bodyless POST succeeds, because an empty payload hashes
|
|
9
|
+
* predictably.
|
|
10
|
+
*
|
|
11
|
+
* `crypto.subtle` needs a secure context, which the streaming behavior already requires
|
|
12
|
+
* (`viewerProtocolPolicy: "https-only"`).
|
|
13
|
+
*/
|
|
14
|
+
export declare function toPayloadHash(body: string): Promise<string>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
async function toPayloadHash(body) {
|
|
2
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
|
|
3
|
+
return Array.from(new Uint8Array(digest)).map((byte)=>byte.toString(16).padStart(2, "0")).join("");
|
|
4
|
+
}
|
|
5
|
+
export { toPayloadHash };
|
|
6
|
+
|
|
7
|
+
//# sourceMappingURL=toPayloadHash.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"features/apiStreamClient/toPayloadHash.js","sources":["../../../src/features/apiStreamClient/toPayloadHash.ts"],"sourcesContent":["/**\n * Hex SHA-256 of a request body, for the `x-amz-content-sha256` header.\n *\n * Required whenever a streaming route is reached through CloudFront with Origin Access Control. OAC\n * signs the request with SigV4 but does NOT hash the body itself — it folds whatever this header says\n * into the signature, and the Lambda Function URL's IAM authorizer then recomputes the hash from the\n * body it received. Omit the header and the two disagree, so a POST WITH a body is rejected with\n * `InvalidSignatureException` while a bodyless POST succeeds, because an empty payload hashes\n * predictably.\n *\n * `crypto.subtle` needs a secure context, which the streaming behavior already requires\n * (`viewerProtocolPolicy: \"https-only\"`).\n */\nexport async function toPayloadHash(body: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(body));\n\n return Array.from(new Uint8Array(digest))\n .map(byte => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n"],"names":["toPayloadHash","body","digest","crypto","TextEncoder","Array","Uint8Array","byte"],"mappings":"AAaO,eAAeA,cAAcC,IAAY;IAC5C,MAAMC,SAAS,MAAMC,OAAO,MAAM,CAAC,MAAM,CAAC,WAAW,IAAIC,cAAc,MAAM,CAACH;IAE9E,OAAOI,MAAM,IAAI,CAAC,IAAIC,WAAWJ,SAC5B,GAAG,CAACK,CAAAA,OAAQA,KAAK,QAAQ,CAAC,IAAI,QAAQ,CAAC,GAAG,MAC1C,IAAI,CAAC;AACd"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webiny/app",
|
|
3
|
-
"version": "6.6.0-alpha.
|
|
3
|
+
"version": "6.6.0-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.js",
|
|
@@ -19,31 +19,31 @@
|
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@types/react": "18.3.31",
|
|
22
|
-
"@webiny/di": "1.0
|
|
23
|
-
"@webiny/feature": "6.6.0-alpha.
|
|
24
|
-
"@webiny/i18n": "6.6.0-alpha.
|
|
25
|
-
"@webiny/i18n-react": "6.6.0-alpha.
|
|
26
|
-
"@webiny/plugins": "6.6.0-alpha.
|
|
27
|
-
"@webiny/react-composition": "6.6.0-alpha.
|
|
28
|
-
"@webiny/react-properties": "6.6.0-alpha.
|
|
22
|
+
"@webiny/di": "1.1.0",
|
|
23
|
+
"@webiny/feature": "6.6.0-alpha.2",
|
|
24
|
+
"@webiny/i18n": "6.6.0-alpha.2",
|
|
25
|
+
"@webiny/i18n-react": "6.6.0-alpha.2",
|
|
26
|
+
"@webiny/plugins": "6.6.0-alpha.2",
|
|
27
|
+
"@webiny/react-composition": "6.6.0-alpha.2",
|
|
28
|
+
"@webiny/react-properties": "6.6.0-alpha.2",
|
|
29
29
|
"graphql": "16.14.2",
|
|
30
30
|
"history": "5.3.0",
|
|
31
31
|
"invariant": "2.2.4",
|
|
32
32
|
"lodash": "4.18.1",
|
|
33
|
-
"mobx": "
|
|
33
|
+
"mobx": "7.0.3",
|
|
34
34
|
"react": "18.3.1",
|
|
35
35
|
"react-dom": "18.3.1",
|
|
36
36
|
"warning": "4.0.3",
|
|
37
37
|
"zod": "4.4.3"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/lodash": "4.17.
|
|
40
|
+
"@types/lodash": "4.17.25",
|
|
41
41
|
"@types/warning": "3.0.4",
|
|
42
|
-
"@webiny/build-tools": "6.6.0-alpha.
|
|
42
|
+
"@webiny/build-tools": "6.6.0-alpha.2",
|
|
43
43
|
"rimraf": "6.1.3",
|
|
44
44
|
"type-fest": "5.8.0",
|
|
45
45
|
"typescript": "7.0.2",
|
|
46
|
-
"vitest": "4.1.
|
|
46
|
+
"vitest": "4.1.11"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"access": "public"
|