@tapcue/extension-sdk 0.1.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/LICENSE +21 -0
- package/README.md +28 -0
- package/package.json +45 -0
- package/src/capabilities.ts +1037 -0
- package/src/components.ts +277 -0
- package/src/define-extension.ts +205 -0
- package/src/errors.ts +90 -0
- package/src/i18n.ts +64 -0
- package/src/index.ts +13 -0
- package/src/json.ts +24 -0
- package/src/jsx-runtime.ts +118 -0
- package/src/manifest.ts +1572 -0
- package/src/overlay-jsx-runtime.ts +72 -0
- package/src/overlay.ts +130 -0
- package/src/permission-units.ts +109 -0
- package/src/reactive.ts +374 -0
- package/src/scene.ts +297 -0
- package/src/testing/http-fake.ts +240 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-host.ts +2330 -0
- package/src/types.ts +614 -0
- package/src/view-runtime.ts +270 -0
- package/src/view.ts +116 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fake HTTP broker used by the deterministic test host.
|
|
3
|
+
*
|
|
4
|
+
* It enforces the same rules the Rust broker does — HTTPS only, exact-host
|
|
5
|
+
* allowlist, host re-check after every redirect, response-size and concurrency
|
|
6
|
+
* budgets, cooperative abort — so a test that passes here is testing the real
|
|
7
|
+
* policy, not a friendlier one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { HttpMethod, HttpRequestInit, HttpResponse } from "../capabilities.js";
|
|
11
|
+
import { ExtensionError, cancelledError } from "../errors.js";
|
|
12
|
+
import type { JsonValue } from "../json.js";
|
|
13
|
+
|
|
14
|
+
export interface RecordedRequest {
|
|
15
|
+
url: string;
|
|
16
|
+
method: HttpMethod;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
body?: string;
|
|
19
|
+
/** Redirect hop index; 0 is the request the extension made. */
|
|
20
|
+
hop: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FakeHttpResponse {
|
|
24
|
+
status?: number;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
json?: JsonValue;
|
|
27
|
+
text?: string;
|
|
28
|
+
bytes?: Uint8Array;
|
|
29
|
+
/** Shorthand for a 302 to another URL. The host re-checks the new host. */
|
|
30
|
+
redirectTo?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HttpRoute {
|
|
34
|
+
/** String matches by prefix (so query strings stay flexible); RegExp by test. */
|
|
35
|
+
url: string | RegExp;
|
|
36
|
+
method?: HttpMethod;
|
|
37
|
+
response:
|
|
38
|
+
| FakeHttpResponse
|
|
39
|
+
| ((request: RecordedRequest) => FakeHttpResponse | Promise<FakeHttpResponse>);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HttpBrokerLimits {
|
|
43
|
+
maxRedirects: number;
|
|
44
|
+
maxConcurrentRequests: number;
|
|
45
|
+
maxResponseBytes: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HttpBrokerOptions {
|
|
49
|
+
allowedHosts: readonly string[];
|
|
50
|
+
routes: readonly HttpRoute[];
|
|
51
|
+
limits: HttpBrokerLimits;
|
|
52
|
+
record(request: RecordedRequest): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
|
|
57
|
+
export class FakeHttpBroker {
|
|
58
|
+
private inFlight = 0;
|
|
59
|
+
|
|
60
|
+
constructor(private readonly options: HttpBrokerOptions) {}
|
|
61
|
+
|
|
62
|
+
async fetch(
|
|
63
|
+
rawUrl: string,
|
|
64
|
+
init: HttpRequestInit | undefined,
|
|
65
|
+
invocationSignal: AbortSignal,
|
|
66
|
+
pinnedHeaders: Record<string, string>,
|
|
67
|
+
): Promise<HttpResponse> {
|
|
68
|
+
const { limits } = this.options;
|
|
69
|
+
if (this.inFlight >= limits.maxConcurrentRequests) {
|
|
70
|
+
throw new ExtensionError({
|
|
71
|
+
code: "quota-exceeded",
|
|
72
|
+
message: `at most ${limits.maxConcurrentRequests} concurrent requests`,
|
|
73
|
+
retryable: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
this.inFlight += 1;
|
|
77
|
+
try {
|
|
78
|
+
return await this.follow(rawUrl, init, invocationSignal, pinnedHeaders);
|
|
79
|
+
} finally {
|
|
80
|
+
this.inFlight -= 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async follow(
|
|
85
|
+
rawUrl: string,
|
|
86
|
+
init: HttpRequestInit | undefined,
|
|
87
|
+
invocationSignal: AbortSignal,
|
|
88
|
+
pinnedHeaders: Record<string, string>,
|
|
89
|
+
): Promise<HttpResponse> {
|
|
90
|
+
const { limits } = this.options;
|
|
91
|
+
let url = rawUrl;
|
|
92
|
+
|
|
93
|
+
for (let hop = 0; hop <= limits.maxRedirects; hop += 1) {
|
|
94
|
+
this.checkDestination(url);
|
|
95
|
+
throwIfAborted(init?.signal, invocationSignal);
|
|
96
|
+
|
|
97
|
+
const request: RecordedRequest = {
|
|
98
|
+
url,
|
|
99
|
+
method: init?.method ?? "GET",
|
|
100
|
+
headers: { ...pinnedHeaders, ...(init?.headers ?? {}) },
|
|
101
|
+
body: typeof init?.body === "string" ? init.body : undefined,
|
|
102
|
+
hop,
|
|
103
|
+
};
|
|
104
|
+
this.options.record(request);
|
|
105
|
+
|
|
106
|
+
const route = this.options.routes.find(
|
|
107
|
+
(candidate) =>
|
|
108
|
+
matches(candidate.url, url) && (candidate.method ?? "GET") === request.method,
|
|
109
|
+
);
|
|
110
|
+
if (!route) {
|
|
111
|
+
throw new ExtensionError({
|
|
112
|
+
code: "unavailable",
|
|
113
|
+
message: `no fake route for ${redact(url)}`,
|
|
114
|
+
retryable: false,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fake = await race(
|
|
119
|
+
Promise.resolve(
|
|
120
|
+
typeof route.response === "function" ? route.response(request) : route.response,
|
|
121
|
+
),
|
|
122
|
+
init?.signal,
|
|
123
|
+
invocationSignal,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (fake.redirectTo) {
|
|
127
|
+
url = fake.redirectTo;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = encodeBody(fake);
|
|
132
|
+
const cap = Math.min(init?.maxResponseBytes ?? limits.maxResponseBytes, limits.maxResponseBytes);
|
|
133
|
+
if (body.byteLength > cap) {
|
|
134
|
+
throw new ExtensionError({
|
|
135
|
+
code: "quota-exceeded",
|
|
136
|
+
message: `response exceeds ${cap} bytes`,
|
|
137
|
+
retryable: false,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return makeResponse(url, fake.status ?? 200, fake.headers ?? {}, body);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new ExtensionError({
|
|
144
|
+
code: "network",
|
|
145
|
+
message: `too many redirects (limit ${limits.maxRedirects})`,
|
|
146
|
+
retryable: false,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** HTTPS only, exact host, re-checked on every hop — including after a redirect. */
|
|
151
|
+
private checkDestination(rawUrl: string): void {
|
|
152
|
+
let parsed: URL;
|
|
153
|
+
try {
|
|
154
|
+
parsed = new URL(rawUrl);
|
|
155
|
+
} catch {
|
|
156
|
+
throw new ExtensionError({ code: "invalid-request", message: "malformed URL" });
|
|
157
|
+
}
|
|
158
|
+
if (parsed.protocol !== "https:") {
|
|
159
|
+
throw new ExtensionError({
|
|
160
|
+
code: "invalid-request",
|
|
161
|
+
message: `only https is allowed, got ${parsed.protocol.replace(":", "")}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (!this.options.allowedHosts.includes(parsed.hostname)) {
|
|
165
|
+
throw new ExtensionError({
|
|
166
|
+
code: "permission-denied",
|
|
167
|
+
message: `host ${parsed.hostname} is not in the granted host list`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function matches(pattern: string | RegExp, url: string): boolean {
|
|
174
|
+
return typeof pattern === "string" ? url.startsWith(pattern) : pattern.test(url);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function encodeBody(fake: FakeHttpResponse): Uint8Array {
|
|
178
|
+
if (fake.bytes) return fake.bytes;
|
|
179
|
+
if (fake.text !== undefined) return encoder.encode(fake.text);
|
|
180
|
+
if (fake.json !== undefined) return encoder.encode(JSON.stringify(fake.json));
|
|
181
|
+
return new Uint8Array();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function makeResponse(
|
|
185
|
+
url: string,
|
|
186
|
+
status: number,
|
|
187
|
+
headers: Record<string, string>,
|
|
188
|
+
body: Uint8Array,
|
|
189
|
+
): HttpResponse {
|
|
190
|
+
const lowered: Record<string, string> = {};
|
|
191
|
+
for (const [key, value] of Object.entries(headers)) lowered[key.toLowerCase()] = value;
|
|
192
|
+
const decoder = new TextDecoder();
|
|
193
|
+
return {
|
|
194
|
+
url,
|
|
195
|
+
status,
|
|
196
|
+
ok: status >= 200 && status < 300,
|
|
197
|
+
headers: Object.freeze(lowered),
|
|
198
|
+
async text() {
|
|
199
|
+
return decoder.decode(body);
|
|
200
|
+
},
|
|
201
|
+
async json<T>() {
|
|
202
|
+
return JSON.parse(decoder.decode(body)) as T;
|
|
203
|
+
},
|
|
204
|
+
async bytes() {
|
|
205
|
+
return body;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function throwIfAborted(...signals: (AbortSignal | undefined)[]): void {
|
|
211
|
+
for (const signal of signals) {
|
|
212
|
+
if (signal?.aborted) throw cancelledError();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Rejects as soon as any signal aborts, so a pending request cannot outlive it. */
|
|
217
|
+
function race<T>(work: Promise<T>, ...signals: (AbortSignal | undefined)[]): Promise<T> {
|
|
218
|
+
const live = signals.filter((s): s is AbortSignal => s !== undefined);
|
|
219
|
+
if (live.length === 0) return work;
|
|
220
|
+
return Promise.race([
|
|
221
|
+
work,
|
|
222
|
+
...live.map(
|
|
223
|
+
(signal) =>
|
|
224
|
+
new Promise<never>((_resolve, reject) => {
|
|
225
|
+
if (signal.aborted) reject(cancelledError());
|
|
226
|
+
signal.addEventListener("abort", () => reject(cancelledError()), { once: true });
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Diagnostics never carry the query string: it can hold a secret or a location. */
|
|
233
|
+
export function redact(url: string): string {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = new URL(url);
|
|
236
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
237
|
+
} catch {
|
|
238
|
+
return "<malformed url>";
|
|
239
|
+
}
|
|
240
|
+
}
|