@tungthedev/streams-server 0.2.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/CODE_OF_CONDUCT.md +45 -0
- package/CONTRIBUTING.md +76 -0
- package/LICENSE +201 -0
- package/README.md +58 -0
- package/SECURITY.md +42 -0
- package/bin/prisma-streams-server +2 -0
- package/package.json +46 -0
- package/src/app.ts +583 -0
- package/src/app_core.ts +3144 -0
- package/src/app_local.ts +206 -0
- package/src/auth.ts +124 -0
- package/src/auto_tune.ts +69 -0
- package/src/backpressure.ts +66 -0
- package/src/bootstrap.ts +613 -0
- package/src/compute/demo_entry.ts +415 -0
- package/src/compute/demo_site.ts +1242 -0
- package/src/compute/entry.ts +19 -0
- package/src/compute/package_entry.ts +4 -0
- package/src/compute/virtual-modules.d.ts +15 -0
- package/src/compute/worker_module_url.ts +9 -0
- package/src/concurrency_gate.ts +108 -0
- package/src/config.ts +402 -0
- package/src/db/bootstrap_store.ts +9 -0
- package/src/db/db.ts +2424 -0
- package/src/db/schema.ts +925 -0
- package/src/db/sqlite_manifest_snapshot.ts +81 -0
- package/src/db/sqlite_touch_store.ts +491 -0
- package/src/db/sqlite_wal_store.ts +472 -0
- package/src/details/full_mode_details.ts +568 -0
- package/src/expiry_sweeper.ts +47 -0
- package/src/foreground_activity.ts +55 -0
- package/src/hist.ts +169 -0
- package/src/index/binary_fuse.ts +379 -0
- package/src/index/indexer.ts +947 -0
- package/src/index/lexicon_file_cache.ts +261 -0
- package/src/index/lexicon_format.ts +93 -0
- package/src/index/lexicon_indexer.ts +863 -0
- package/src/index/run_cache.ts +84 -0
- package/src/index/run_format.ts +213 -0
- package/src/index/schedule.ts +28 -0
- package/src/index/secondary_indexer.ts +901 -0
- package/src/index/secondary_schema.ts +105 -0
- package/src/ingest.ts +309 -0
- package/src/lens/lens.ts +501 -0
- package/src/manifest.ts +249 -0
- package/src/memory.ts +334 -0
- package/src/metrics.ts +147 -0
- package/src/metrics_emitter.ts +83 -0
- package/src/notifier.ts +180 -0
- package/src/objectstore/accounting.ts +151 -0
- package/src/objectstore/interface.ts +13 -0
- package/src/objectstore/mock_r2.ts +269 -0
- package/src/objectstore/null.ts +32 -0
- package/src/objectstore/r2.ts +318 -0
- package/src/observe/pairing.ts +61 -0
- package/src/observe/request.ts +772 -0
- package/src/offset.ts +70 -0
- package/src/postgres/bootstrap.ts +269 -0
- package/src/postgres/companions.ts +197 -0
- package/src/postgres/control_restore.ts +109 -0
- package/src/postgres/details.ts +189 -0
- package/src/postgres/lexicon_index.ts +260 -0
- package/src/postgres/routing_index.ts +189 -0
- package/src/postgres/rows.ts +132 -0
- package/src/postgres/schema.ts +355 -0
- package/src/postgres/secondary_index.ts +238 -0
- package/src/postgres/segments.ts +900 -0
- package/src/postgres/stats.ts +103 -0
- package/src/postgres/store.ts +947 -0
- package/src/postgres/touch.ts +591 -0
- package/src/postgres/types.ts +32 -0
- package/src/profiles/evlog/schema.ts +234 -0
- package/src/profiles/evlog.ts +473 -0
- package/src/profiles/generic.ts +51 -0
- package/src/profiles/index.ts +237 -0
- package/src/profiles/metrics/block_format.ts +109 -0
- package/src/profiles/metrics/normalize.ts +366 -0
- package/src/profiles/metrics/schema.ts +319 -0
- package/src/profiles/metrics.ts +83 -0
- package/src/profiles/otelTraces/normalize.ts +955 -0
- package/src/profiles/otelTraces/otlp.ts +1002 -0
- package/src/profiles/otelTraces/schema.ts +408 -0
- package/src/profiles/otelTraces.ts +390 -0
- package/src/profiles/profile.ts +284 -0
- package/src/profiles/stateProtocol/change_event_conformance.typecheck.ts +35 -0
- package/src/profiles/stateProtocol/changes.ts +24 -0
- package/src/profiles/stateProtocol/ingest.ts +115 -0
- package/src/profiles/stateProtocol/routes.ts +511 -0
- package/src/profiles/stateProtocol/types.ts +6 -0
- package/src/profiles/stateProtocol/validation.ts +51 -0
- package/src/profiles/stateProtocol.ts +107 -0
- package/src/read_filter.ts +468 -0
- package/src/reader.ts +2986 -0
- package/src/runtime/hash.ts +156 -0
- package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
- package/src/runtime/hash_vendor/NOTICE.md +8 -0
- package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
- package/src/runtime/host_runtime.ts +5 -0
- package/src/runtime_memory.ts +200 -0
- package/src/runtime_memory_sampler.ts +237 -0
- package/src/schema/lens_schema.ts +290 -0
- package/src/schema/proof.ts +547 -0
- package/src/schema/read_json.ts +51 -0
- package/src/schema/registry.ts +966 -0
- package/src/search/agg_format.ts +638 -0
- package/src/search/aggregate.ts +409 -0
- package/src/search/binary/codec.ts +162 -0
- package/src/search/binary/docset.ts +67 -0
- package/src/search/binary/restart_strings.ts +181 -0
- package/src/search/binary/varint.ts +34 -0
- package/src/search/bitset.ts +19 -0
- package/src/search/col_format.ts +382 -0
- package/src/search/col_runtime.ts +59 -0
- package/src/search/column_encoding.ts +43 -0
- package/src/search/companion_file_cache.ts +319 -0
- package/src/search/companion_format.ts +327 -0
- package/src/search/companion_manager.ts +1305 -0
- package/src/search/companion_plan.ts +229 -0
- package/src/search/exact_format.ts +281 -0
- package/src/search/exact_runtime.ts +55 -0
- package/src/search/fts_format.ts +423 -0
- package/src/search/fts_runtime.ts +333 -0
- package/src/search/query.ts +875 -0
- package/src/search/schema.ts +245 -0
- package/src/segment/cache.ts +270 -0
- package/src/segment/cached_segment.ts +89 -0
- package/src/segment/format.ts +403 -0
- package/src/segment/segmenter.ts +412 -0
- package/src/segment/segmenter_worker.ts +72 -0
- package/src/segment/segmenter_workers.ts +130 -0
- package/src/server.ts +264 -0
- package/src/server_auto_tune.ts +158 -0
- package/src/sqlite/adapter.ts +335 -0
- package/src/sqlite/runtime_stats.ts +163 -0
- package/src/stats.ts +205 -0
- package/src/store/append.ts +50 -0
- package/src/store/bootstrap_restore_store.ts +71 -0
- package/src/store/capabilities.ts +86 -0
- package/src/store/full_mode_details_store.ts +71 -0
- package/src/store/index_store.ts +104 -0
- package/src/store/profile_touch_store.ts +1 -0
- package/src/store/rows.ts +144 -0
- package/src/store/schema_profile_store.ts +73 -0
- package/src/store/schema_publication.ts +6 -0
- package/src/store/segment_manifest_store.ts +129 -0
- package/src/store/segment_read_store.ts +22 -0
- package/src/store/stats_accounting_store.ts +83 -0
- package/src/store/touch_store.ts +98 -0
- package/src/store/wal_store.ts +21 -0
- package/src/stream_size_reconciler.ts +100 -0
- package/src/touch/canonical_change.ts +7 -0
- package/src/touch/live_keys.ts +158 -0
- package/src/touch/live_metrics.ts +841 -0
- package/src/touch/live_templates.ts +449 -0
- package/src/touch/manager.ts +1292 -0
- package/src/touch/process_batch.ts +576 -0
- package/src/touch/processor_worker.ts +85 -0
- package/src/touch/spec.ts +459 -0
- package/src/touch/touch_journal.ts +771 -0
- package/src/touch/touch_key_id.ts +20 -0
- package/src/touch/worker_pool.ts +191 -0
- package/src/touch/worker_protocol.ts +57 -0
- package/src/types/proper-lockfile.d.ts +1 -0
- package/src/uploader.ts +358 -0
- package/src/util/base32_crockford.ts +81 -0
- package/src/util/bloom256.ts +67 -0
- package/src/util/byte_lru.ts +73 -0
- package/src/util/cleanup.ts +22 -0
- package/src/util/crc32c.ts +29 -0
- package/src/util/ds_error.ts +15 -0
- package/src/util/duration.ts +17 -0
- package/src/util/endian.ts +53 -0
- package/src/util/json_pointer.ts +148 -0
- package/src/util/log.ts +25 -0
- package/src/util/lru.ts +53 -0
- package/src/util/retry.ts +35 -0
- package/src/util/siphash.ts +71 -0
- package/src/util/stream_paths.ts +50 -0
- package/src/util/time.ts +14 -0
- package/src/util/yield.ts +3 -0
- package/src/util/zstd.ts +24 -0
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
import { dsError } from "../util/ds_error.ts";
|
|
2
|
+
|
|
3
|
+
type BuiltAsset = {
|
|
4
|
+
bytes: Uint8Array | ArrayBuffer;
|
|
5
|
+
contentType: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type PrebuiltStudioAssets = {
|
|
9
|
+
appScript: string;
|
|
10
|
+
appStyles: string;
|
|
11
|
+
builtAssets: Map<string, BuiltAsset>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type GenerateJobStatus = "pending" | "running" | "succeeded" | "failed";
|
|
15
|
+
|
|
16
|
+
type GenerateJobState = {
|
|
17
|
+
id: string;
|
|
18
|
+
stream: string;
|
|
19
|
+
total: number;
|
|
20
|
+
inserted: number;
|
|
21
|
+
batchSize: number;
|
|
22
|
+
status: GenerateJobStatus;
|
|
23
|
+
error: string | null;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
finishedAt: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ComputeDemoSite = {
|
|
29
|
+
close(): void;
|
|
30
|
+
fetch(request: Request): Promise<Response>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type GenerateStreamsTarget = {
|
|
34
|
+
appendGenerateBatch?: (stream: string, events: Array<Record<string, unknown>>) => Promise<void>;
|
|
35
|
+
beginGenerateJob?: (stream: string) => void;
|
|
36
|
+
endGenerateJob?: (stream: string) => void;
|
|
37
|
+
ensureGenerateStream?: (stream: string) => Promise<void>;
|
|
38
|
+
fetch(request: Request): Promise<Response>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type CreateComputeDemoSiteOptions = {
|
|
42
|
+
bootId?: string;
|
|
43
|
+
studioAssets: PrebuiltStudioAssets;
|
|
44
|
+
streamsApp: GenerateStreamsTarget;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const APP_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
48
|
+
const NO_STORE_CACHE_CONTROL = "no-cache, no-store, must-revalidate";
|
|
49
|
+
const STUDIO_ROOT_PATH = "/studio";
|
|
50
|
+
const STUDIO_STREAMS_PROXY_BASE_PATH = "/studio/api/streams";
|
|
51
|
+
const STUDIO_CONFIG_PATH = "/api/config";
|
|
52
|
+
const STUDIO_QUERY_PATH = "/api/query";
|
|
53
|
+
const STUDIO_AI_PATH = "/api/ai";
|
|
54
|
+
const GENERATE_ROOT_PATH = "/generate";
|
|
55
|
+
const GENERATE_JOBS_BASE_PATH = "/api/generate/jobs";
|
|
56
|
+
const JOB_RETENTION_MS = 10 * 60_000;
|
|
57
|
+
const GENERATE_BUTTON_COUNTS = new Set([1_000, 10_000, 100_000]);
|
|
58
|
+
const GENERATE_BATCH_TARGET_BYTES = 256 * 1024;
|
|
59
|
+
const GENERATE_BATCH_MIN_EVENTS = 100;
|
|
60
|
+
const GENERATE_BATCH_MAX_EVENTS = 500;
|
|
61
|
+
const GENERATE_GC_MIN_TOTAL_EVENTS = 100_000;
|
|
62
|
+
const GENERATE_GC_ROW_INTERVAL = 50_000;
|
|
63
|
+
const DEFAULT_GENERATE_STREAM = "demo-app";
|
|
64
|
+
const GENERATE_STREAM_NAME_MAX_LENGTH = 128;
|
|
65
|
+
const GENERATE_STREAM_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
66
|
+
|
|
67
|
+
const GENERATE_METHODS = ["GET", "POST", "PUT", "PATCH"] as const;
|
|
68
|
+
const GENERATE_PATHS = [
|
|
69
|
+
"/api/orders",
|
|
70
|
+
"/api/orders/:id",
|
|
71
|
+
"/api/checkout",
|
|
72
|
+
"/api/invoices",
|
|
73
|
+
"/api/shipments/:id",
|
|
74
|
+
"/api/catalog/search",
|
|
75
|
+
] as const;
|
|
76
|
+
const GENERATE_SERVICES = [
|
|
77
|
+
"billing",
|
|
78
|
+
"checkout",
|
|
79
|
+
"fulfillment",
|
|
80
|
+
"identity",
|
|
81
|
+
"inventory",
|
|
82
|
+
"search",
|
|
83
|
+
] as const;
|
|
84
|
+
const GENERATE_ENVIRONMENTS = ["prod", "staging"] as const;
|
|
85
|
+
const GENERATE_REGIONS = ["cdg", "fra", "iad"] as const;
|
|
86
|
+
|
|
87
|
+
function clamp(value: number, min: number, max: number): number {
|
|
88
|
+
return Math.max(min, Math.min(max, value));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function jsonResponse(
|
|
92
|
+
status: number,
|
|
93
|
+
payload: unknown,
|
|
94
|
+
headers?: HeadersInit,
|
|
95
|
+
): Response {
|
|
96
|
+
return new Response(JSON.stringify(payload), {
|
|
97
|
+
headers: {
|
|
98
|
+
"cache-control": NO_STORE_CACHE_CONTROL,
|
|
99
|
+
"content-type": "application/json; charset=utf-8",
|
|
100
|
+
...headers,
|
|
101
|
+
},
|
|
102
|
+
status,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function methodNotAllowed(allow: string): Response {
|
|
107
|
+
return new Response("Method Not Allowed", {
|
|
108
|
+
headers: {
|
|
109
|
+
Allow: allow,
|
|
110
|
+
"cache-control": NO_STORE_CACHE_CONTROL,
|
|
111
|
+
},
|
|
112
|
+
status: 405,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collectGenerateBatchMemory(job: GenerateJobState, previousInserted: number): void {
|
|
117
|
+
if (job.total < GENERATE_GC_MIN_TOTAL_EVENTS) return;
|
|
118
|
+
const crossedBoundary =
|
|
119
|
+
Math.floor(previousInserted / GENERATE_GC_ROW_INTERVAL) !==
|
|
120
|
+
Math.floor(job.inserted / GENERATE_GC_ROW_INTERVAL);
|
|
121
|
+
if (!crossedBoundary && job.inserted < job.total) return;
|
|
122
|
+
|
|
123
|
+
const gc = (globalThis as { Bun?: { gc?: (force?: boolean) => void } }).Bun?.gc;
|
|
124
|
+
if (typeof gc !== "function") return;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
gc(true);
|
|
128
|
+
} catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function responseBodyForMethod(
|
|
134
|
+
request: Request,
|
|
135
|
+
body: BodyInit | null,
|
|
136
|
+
): BodyInit | null {
|
|
137
|
+
return request.method === "HEAD" ? null : body;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function textResponse(
|
|
141
|
+
request: Request,
|
|
142
|
+
body: string,
|
|
143
|
+
init: ResponseInit & { contentType: string },
|
|
144
|
+
): Response {
|
|
145
|
+
const headers = new Headers(init.headers);
|
|
146
|
+
if (!headers.has("cache-control")) {
|
|
147
|
+
headers.set("cache-control", NO_STORE_CACHE_CONTROL);
|
|
148
|
+
}
|
|
149
|
+
headers.set("content-type", init.contentType);
|
|
150
|
+
|
|
151
|
+
return new Response(responseBodyForMethod(request, body), {
|
|
152
|
+
headers,
|
|
153
|
+
status: init.status,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeBuiltAssetPath(pathname: string): string[] {
|
|
158
|
+
const paths = [pathname];
|
|
159
|
+
try {
|
|
160
|
+
const decoded = decodeURIComponent(pathname);
|
|
161
|
+
if (decoded !== pathname) {
|
|
162
|
+
paths.push(decoded);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore malformed URI sequences and keep the raw pathname only.
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const candidate of [...paths]) {
|
|
169
|
+
if (candidate.startsWith(`${STUDIO_ROOT_PATH}/`)) {
|
|
170
|
+
paths.push(candidate.slice(STUDIO_ROOT_PATH.length));
|
|
171
|
+
} else if (candidate.startsWith("/") && candidate !== STUDIO_ROOT_PATH) {
|
|
172
|
+
paths.push(`${STUDIO_ROOT_PATH}${candidate}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return [...new Set(paths)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function createEntropyHex(stream: string, index: number, byteLength = 64): string {
|
|
179
|
+
let state = 0x811c9dc5;
|
|
180
|
+
for (let position = 0; position < stream.length; position += 1) {
|
|
181
|
+
state ^= stream.charCodeAt(position);
|
|
182
|
+
state = Math.imul(state, 0x01000193);
|
|
183
|
+
}
|
|
184
|
+
state ^= index >>> 0;
|
|
185
|
+
state = Math.imul(state, 0x9e3779b1);
|
|
186
|
+
|
|
187
|
+
const alphabet = "0123456789abcdef";
|
|
188
|
+
let out = "";
|
|
189
|
+
for (let emitted = 0; emitted < byteLength; emitted += 1) {
|
|
190
|
+
state ^= state << 13;
|
|
191
|
+
state ^= state >>> 17;
|
|
192
|
+
state ^= state << 5;
|
|
193
|
+
const value = state >>> 0;
|
|
194
|
+
out += alphabet[(value >>> 4) & 0x0f];
|
|
195
|
+
out += alphabet[value & 0x0f];
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function createStudioHtmlDocument(): string {
|
|
201
|
+
return `<!doctype html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="utf-8" />
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
206
|
+
<title>Streams Studio</title>
|
|
207
|
+
<link rel="stylesheet" href="${STUDIO_ROOT_PATH}/app.css" />
|
|
208
|
+
</head>
|
|
209
|
+
<body>
|
|
210
|
+
<div id="root"></div>
|
|
211
|
+
<script type="module" src="${STUDIO_ROOT_PATH}/app.js"></script>
|
|
212
|
+
</body>
|
|
213
|
+
</html>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function createLandingHtmlDocument(): string {
|
|
217
|
+
return `<!doctype html>
|
|
218
|
+
<html lang="en">
|
|
219
|
+
<head>
|
|
220
|
+
<meta charset="utf-8" />
|
|
221
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
222
|
+
<title>Streams Compute Demo</title>
|
|
223
|
+
<style>
|
|
224
|
+
:root {
|
|
225
|
+
--bg: #071421;
|
|
226
|
+
--bg-accent: #12304a;
|
|
227
|
+
--panel: rgba(7, 20, 33, 0.82);
|
|
228
|
+
--panel-border: rgba(148, 184, 210, 0.26);
|
|
229
|
+
--text: #e6f1fb;
|
|
230
|
+
--muted: #9fb6cb;
|
|
231
|
+
--primary: #66d9ff;
|
|
232
|
+
--primary-strong: #b5ffcc;
|
|
233
|
+
--shadow: 0 24px 70px rgba(1, 8, 16, 0.45);
|
|
234
|
+
}
|
|
235
|
+
* { box-sizing: border-box; }
|
|
236
|
+
body {
|
|
237
|
+
margin: 0;
|
|
238
|
+
min-height: 100vh;
|
|
239
|
+
display: grid;
|
|
240
|
+
place-items: center;
|
|
241
|
+
padding: 32px;
|
|
242
|
+
color: var(--text);
|
|
243
|
+
background:
|
|
244
|
+
radial-gradient(circle at top left, rgba(102, 217, 255, 0.2), transparent 34%),
|
|
245
|
+
radial-gradient(circle at bottom right, rgba(181, 255, 204, 0.18), transparent 30%),
|
|
246
|
+
linear-gradient(135deg, var(--bg), var(--bg-accent));
|
|
247
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
248
|
+
}
|
|
249
|
+
main {
|
|
250
|
+
width: min(980px, 100%);
|
|
251
|
+
padding: 32px;
|
|
252
|
+
border: 1px solid var(--panel-border);
|
|
253
|
+
border-radius: 28px;
|
|
254
|
+
background: var(--panel);
|
|
255
|
+
backdrop-filter: blur(24px);
|
|
256
|
+
box-shadow: var(--shadow);
|
|
257
|
+
}
|
|
258
|
+
h1 {
|
|
259
|
+
margin: 0;
|
|
260
|
+
font-size: clamp(2.4rem, 5vw, 4rem);
|
|
261
|
+
line-height: 0.96;
|
|
262
|
+
letter-spacing: -0.04em;
|
|
263
|
+
}
|
|
264
|
+
p {
|
|
265
|
+
margin: 16px 0 0;
|
|
266
|
+
max-width: 56ch;
|
|
267
|
+
color: var(--muted);
|
|
268
|
+
font-size: 1.05rem;
|
|
269
|
+
line-height: 1.6;
|
|
270
|
+
}
|
|
271
|
+
.grid {
|
|
272
|
+
display: grid;
|
|
273
|
+
gap: 18px;
|
|
274
|
+
margin-top: 28px;
|
|
275
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
276
|
+
}
|
|
277
|
+
.card {
|
|
278
|
+
display: block;
|
|
279
|
+
padding: 22px;
|
|
280
|
+
border-radius: 22px;
|
|
281
|
+
border: 1px solid rgba(148, 184, 210, 0.18);
|
|
282
|
+
background: linear-gradient(180deg, rgba(10, 28, 43, 0.92), rgba(6, 16, 28, 0.92));
|
|
283
|
+
color: inherit;
|
|
284
|
+
text-decoration: none;
|
|
285
|
+
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
|
286
|
+
}
|
|
287
|
+
.card:hover {
|
|
288
|
+
transform: translateY(-2px);
|
|
289
|
+
border-color: rgba(102, 217, 255, 0.5);
|
|
290
|
+
background: linear-gradient(180deg, rgba(15, 38, 57, 0.96), rgba(7, 18, 30, 0.96));
|
|
291
|
+
}
|
|
292
|
+
.eyebrow {
|
|
293
|
+
display: inline-flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
padding: 6px 10px;
|
|
296
|
+
border-radius: 999px;
|
|
297
|
+
background: rgba(102, 217, 255, 0.12);
|
|
298
|
+
color: var(--primary);
|
|
299
|
+
font-size: 0.75rem;
|
|
300
|
+
font-weight: 700;
|
|
301
|
+
letter-spacing: 0.12em;
|
|
302
|
+
text-transform: uppercase;
|
|
303
|
+
}
|
|
304
|
+
.title {
|
|
305
|
+
margin-top: 14px;
|
|
306
|
+
font-size: 1.25rem;
|
|
307
|
+
font-weight: 700;
|
|
308
|
+
}
|
|
309
|
+
.summary {
|
|
310
|
+
margin-top: 10px;
|
|
311
|
+
color: var(--muted);
|
|
312
|
+
line-height: 1.55;
|
|
313
|
+
}
|
|
314
|
+
.go {
|
|
315
|
+
margin-top: 18px;
|
|
316
|
+
color: var(--primary-strong);
|
|
317
|
+
font-weight: 700;
|
|
318
|
+
}
|
|
319
|
+
</style>
|
|
320
|
+
</head>
|
|
321
|
+
<body>
|
|
322
|
+
<main>
|
|
323
|
+
<span class="eyebrow">Compute Demo</span>
|
|
324
|
+
<h1>Streams, with Studio and an evlog generator.</h1>
|
|
325
|
+
<p>
|
|
326
|
+
This deployment keeps the normal Streams API on <code>/v1/*</code>, serves Prisma Studio on <code>/studio</code>,
|
|
327
|
+
and adds a write generator on <code>/generate</code> for bulk evlog ingestion against the same server.
|
|
328
|
+
</p>
|
|
329
|
+
<div class="grid">
|
|
330
|
+
<a class="card" href="${STUDIO_ROOT_PATH}">
|
|
331
|
+
<span class="eyebrow">Inspect</span>
|
|
332
|
+
<div class="title">Open Studio</div>
|
|
333
|
+
<div class="summary">
|
|
334
|
+
Browse streams, inspect details, and query the live server through the bundled Studio UI.
|
|
335
|
+
</div>
|
|
336
|
+
<div class="go">Launch Studio</div>
|
|
337
|
+
</a>
|
|
338
|
+
<a class="card" href="${GENERATE_ROOT_PATH}">
|
|
339
|
+
<span class="eyebrow">Ingest</span>
|
|
340
|
+
<div class="title">Open Generator</div>
|
|
341
|
+
<div class="summary">
|
|
342
|
+
Create a fresh evlog stream and insert 1k, 10k, or 100k canonical request events with live progress.
|
|
343
|
+
</div>
|
|
344
|
+
<div class="go">Launch Generator</div>
|
|
345
|
+
</a>
|
|
346
|
+
</div>
|
|
347
|
+
</main>
|
|
348
|
+
</body>
|
|
349
|
+
</html>`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function createGenerateHtmlDocument(): string {
|
|
353
|
+
return `<!doctype html>
|
|
354
|
+
<html lang="en">
|
|
355
|
+
<head>
|
|
356
|
+
<meta charset="utf-8" />
|
|
357
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
358
|
+
<title>Streams Evlog Generator</title>
|
|
359
|
+
<style>
|
|
360
|
+
:root {
|
|
361
|
+
--page-top: #07121e;
|
|
362
|
+
--page-bottom: #10283d;
|
|
363
|
+
--panel: rgba(7, 18, 30, 0.84);
|
|
364
|
+
--panel-border: rgba(150, 194, 220, 0.22);
|
|
365
|
+
--text: #e9f3fb;
|
|
366
|
+
--muted: #a6bccd;
|
|
367
|
+
--button: #d9ff70;
|
|
368
|
+
--button-hover: #f1ffb8;
|
|
369
|
+
--button-text: #09131d;
|
|
370
|
+
--track: rgba(163, 186, 201, 0.16);
|
|
371
|
+
--fill-start: #66d9ff;
|
|
372
|
+
--fill-end: #b5ffcc;
|
|
373
|
+
--danger: #ff9f8e;
|
|
374
|
+
}
|
|
375
|
+
* { box-sizing: border-box; }
|
|
376
|
+
body {
|
|
377
|
+
margin: 0;
|
|
378
|
+
min-height: 100vh;
|
|
379
|
+
padding: 28px;
|
|
380
|
+
color: var(--text);
|
|
381
|
+
background:
|
|
382
|
+
radial-gradient(circle at top left, rgba(102, 217, 255, 0.16), transparent 32%),
|
|
383
|
+
radial-gradient(circle at bottom right, rgba(217, 255, 112, 0.14), transparent 24%),
|
|
384
|
+
linear-gradient(180deg, var(--page-top), var(--page-bottom));
|
|
385
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
386
|
+
}
|
|
387
|
+
main {
|
|
388
|
+
width: min(920px, 100%);
|
|
389
|
+
margin: 0 auto;
|
|
390
|
+
padding: 30px;
|
|
391
|
+
border-radius: 28px;
|
|
392
|
+
border: 1px solid var(--panel-border);
|
|
393
|
+
background: var(--panel);
|
|
394
|
+
backdrop-filter: blur(24px);
|
|
395
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
|
|
396
|
+
}
|
|
397
|
+
h1 {
|
|
398
|
+
margin: 0;
|
|
399
|
+
font-size: clamp(2rem, 4vw, 3.4rem);
|
|
400
|
+
line-height: 0.95;
|
|
401
|
+
letter-spacing: -0.04em;
|
|
402
|
+
}
|
|
403
|
+
p {
|
|
404
|
+
margin: 16px 0 0;
|
|
405
|
+
color: var(--muted);
|
|
406
|
+
line-height: 1.6;
|
|
407
|
+
max-width: 58ch;
|
|
408
|
+
}
|
|
409
|
+
.actions {
|
|
410
|
+
display: grid;
|
|
411
|
+
gap: 12px;
|
|
412
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
413
|
+
margin-top: 26px;
|
|
414
|
+
}
|
|
415
|
+
.field {
|
|
416
|
+
display: grid;
|
|
417
|
+
gap: 8px;
|
|
418
|
+
margin-top: 24px;
|
|
419
|
+
}
|
|
420
|
+
.field label {
|
|
421
|
+
color: var(--muted);
|
|
422
|
+
font-size: 0.82rem;
|
|
423
|
+
font-weight: 800;
|
|
424
|
+
letter-spacing: 0.12em;
|
|
425
|
+
text-transform: uppercase;
|
|
426
|
+
}
|
|
427
|
+
.field input {
|
|
428
|
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
429
|
+
border-radius: 16px;
|
|
430
|
+
background: rgba(255, 255, 255, 0.08);
|
|
431
|
+
color: var(--text);
|
|
432
|
+
font: inherit;
|
|
433
|
+
font-size: 1rem;
|
|
434
|
+
padding: 13px 15px;
|
|
435
|
+
outline: none;
|
|
436
|
+
}
|
|
437
|
+
.field input:focus {
|
|
438
|
+
border-color: rgba(181, 255, 204, 0.78);
|
|
439
|
+
box-shadow: 0 0 0 4px rgba(181, 255, 204, 0.12);
|
|
440
|
+
}
|
|
441
|
+
.hint {
|
|
442
|
+
color: var(--muted);
|
|
443
|
+
font-size: 0.88rem;
|
|
444
|
+
}
|
|
445
|
+
button {
|
|
446
|
+
border: 0;
|
|
447
|
+
border-radius: 18px;
|
|
448
|
+
padding: 18px 20px;
|
|
449
|
+
font: inherit;
|
|
450
|
+
font-weight: 800;
|
|
451
|
+
letter-spacing: 0.02em;
|
|
452
|
+
background: linear-gradient(135deg, var(--button), #9cf7ff);
|
|
453
|
+
color: var(--button-text);
|
|
454
|
+
cursor: pointer;
|
|
455
|
+
transition: transform 140ms ease, filter 140ms ease, opacity 140ms ease;
|
|
456
|
+
}
|
|
457
|
+
button:hover:enabled {
|
|
458
|
+
transform: translateY(-1px);
|
|
459
|
+
filter: brightness(1.05);
|
|
460
|
+
}
|
|
461
|
+
button:disabled {
|
|
462
|
+
cursor: not-allowed;
|
|
463
|
+
opacity: 0.5;
|
|
464
|
+
}
|
|
465
|
+
.panel {
|
|
466
|
+
margin-top: 24px;
|
|
467
|
+
padding: 20px;
|
|
468
|
+
border-radius: 22px;
|
|
469
|
+
border: 1px solid rgba(150, 194, 220, 0.14);
|
|
470
|
+
background: rgba(9, 25, 39, 0.72);
|
|
471
|
+
}
|
|
472
|
+
.label {
|
|
473
|
+
font-size: 0.78rem;
|
|
474
|
+
letter-spacing: 0.12em;
|
|
475
|
+
text-transform: uppercase;
|
|
476
|
+
color: #73dfff;
|
|
477
|
+
}
|
|
478
|
+
.status {
|
|
479
|
+
margin-top: 10px;
|
|
480
|
+
font-size: 1.2rem;
|
|
481
|
+
font-weight: 700;
|
|
482
|
+
}
|
|
483
|
+
.meta {
|
|
484
|
+
display: grid;
|
|
485
|
+
gap: 10px 18px;
|
|
486
|
+
margin-top: 16px;
|
|
487
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
488
|
+
color: var(--muted);
|
|
489
|
+
}
|
|
490
|
+
.meta strong {
|
|
491
|
+
display: block;
|
|
492
|
+
margin-bottom: 4px;
|
|
493
|
+
color: var(--text);
|
|
494
|
+
font-size: 0.9rem;
|
|
495
|
+
}
|
|
496
|
+
.progress {
|
|
497
|
+
margin-top: 18px;
|
|
498
|
+
height: 16px;
|
|
499
|
+
border-radius: 999px;
|
|
500
|
+
overflow: hidden;
|
|
501
|
+
background: var(--track);
|
|
502
|
+
}
|
|
503
|
+
.progress > div {
|
|
504
|
+
height: 100%;
|
|
505
|
+
width: 0%;
|
|
506
|
+
background: linear-gradient(90deg, var(--fill-start), var(--fill-end));
|
|
507
|
+
transition: width 160ms ease;
|
|
508
|
+
}
|
|
509
|
+
.numbers {
|
|
510
|
+
margin-top: 12px;
|
|
511
|
+
display: flex;
|
|
512
|
+
justify-content: space-between;
|
|
513
|
+
gap: 16px;
|
|
514
|
+
color: var(--muted);
|
|
515
|
+
font-variant-numeric: tabular-nums;
|
|
516
|
+
}
|
|
517
|
+
.links {
|
|
518
|
+
display: flex;
|
|
519
|
+
flex-wrap: wrap;
|
|
520
|
+
gap: 12px;
|
|
521
|
+
margin-top: 18px;
|
|
522
|
+
}
|
|
523
|
+
.links a {
|
|
524
|
+
color: #b5ffcc;
|
|
525
|
+
font-weight: 700;
|
|
526
|
+
text-decoration: none;
|
|
527
|
+
}
|
|
528
|
+
.links a:hover {
|
|
529
|
+
text-decoration: underline;
|
|
530
|
+
}
|
|
531
|
+
.error {
|
|
532
|
+
margin-top: 16px;
|
|
533
|
+
color: var(--danger);
|
|
534
|
+
white-space: pre-wrap;
|
|
535
|
+
}
|
|
536
|
+
</style>
|
|
537
|
+
</head>
|
|
538
|
+
<body>
|
|
539
|
+
<main>
|
|
540
|
+
<div class="label">Evlog Ingest</div>
|
|
541
|
+
<h1>Generate canonical request events.</h1>
|
|
542
|
+
<p>
|
|
543
|
+
Each run uses the selected <code>application/json</code> stream, installs the <code>evlog</code> profile,
|
|
544
|
+
and appends the selected number of events in server-side chunks sized for steady progress updates.
|
|
545
|
+
</p>
|
|
546
|
+
|
|
547
|
+
<div class="field">
|
|
548
|
+
<label for="streamInput">Stream name</label>
|
|
549
|
+
<input id="streamInput" name="stream" type="text" value="${DEFAULT_GENERATE_STREAM}" autocomplete="off" spellcheck="false" maxlength="${GENERATE_STREAM_NAME_MAX_LENGTH}" />
|
|
550
|
+
<div class="hint">Use letters, numbers, dot, underscore, slash, or hyphen. Default: <code>${DEFAULT_GENERATE_STREAM}</code>.</div>
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
<div class="actions">
|
|
554
|
+
<button type="button" data-count="1000">Insert 1k</button>
|
|
555
|
+
<button type="button" data-count="10000">Insert 10k</button>
|
|
556
|
+
<button type="button" data-count="100000">Insert 100k</button>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
<section class="panel">
|
|
560
|
+
<div class="label">Progress</div>
|
|
561
|
+
<div id="status" class="status">Ready to start.</div>
|
|
562
|
+
<div class="progress"><div id="progressFill"></div></div>
|
|
563
|
+
<div class="numbers">
|
|
564
|
+
<div id="progressNumbers">0 / 0</div>
|
|
565
|
+
<div id="progressPercent">0%</div>
|
|
566
|
+
</div>
|
|
567
|
+
<div class="meta">
|
|
568
|
+
<div>
|
|
569
|
+
<strong>Stream</strong>
|
|
570
|
+
<span id="streamName">Not started</span>
|
|
571
|
+
</div>
|
|
572
|
+
<div>
|
|
573
|
+
<strong>Batch Size</strong>
|
|
574
|
+
<span id="batchSize">-</span>
|
|
575
|
+
</div>
|
|
576
|
+
<div>
|
|
577
|
+
<strong>Status</strong>
|
|
578
|
+
<span id="jobState">idle</span>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="links">
|
|
582
|
+
<a href="${STUDIO_ROOT_PATH}">Open Studio</a>
|
|
583
|
+
<a id="detailsLink" href="#" hidden>Open Stream Details</a>
|
|
584
|
+
</div>
|
|
585
|
+
<div id="error" class="error" hidden></div>
|
|
586
|
+
</section>
|
|
587
|
+
</main>
|
|
588
|
+
|
|
589
|
+
<script>
|
|
590
|
+
const buttons = Array.from(document.querySelectorAll("button[data-count]"));
|
|
591
|
+
const statusEl = document.getElementById("status");
|
|
592
|
+
const fillEl = document.getElementById("progressFill");
|
|
593
|
+
const progressNumbersEl = document.getElementById("progressNumbers");
|
|
594
|
+
const progressPercentEl = document.getElementById("progressPercent");
|
|
595
|
+
const streamNameEl = document.getElementById("streamName");
|
|
596
|
+
const streamInputEl = document.getElementById("streamInput");
|
|
597
|
+
const batchSizeEl = document.getElementById("batchSize");
|
|
598
|
+
const jobStateEl = document.getElementById("jobState");
|
|
599
|
+
const errorEl = document.getElementById("error");
|
|
600
|
+
const detailsLink = document.getElementById("detailsLink");
|
|
601
|
+
|
|
602
|
+
let pollTimer = null;
|
|
603
|
+
let currentJobId = null;
|
|
604
|
+
|
|
605
|
+
const setButtonsDisabled = (disabled) => {
|
|
606
|
+
for (const button of buttons) {
|
|
607
|
+
button.disabled = disabled;
|
|
608
|
+
}
|
|
609
|
+
streamInputEl.disabled = disabled;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const readStreamName = () => streamInputEl.value.trim();
|
|
613
|
+
|
|
614
|
+
const renderJob = (job) => {
|
|
615
|
+
const total = job?.total ?? 0;
|
|
616
|
+
const inserted = job?.inserted ?? 0;
|
|
617
|
+
const percent = total > 0 ? Math.min(100, (inserted / total) * 100) : 0;
|
|
618
|
+
statusEl.textContent =
|
|
619
|
+
job == null
|
|
620
|
+
? "Ready to start."
|
|
621
|
+
: job.status === "failed"
|
|
622
|
+
? "Insert failed."
|
|
623
|
+
: job.status === "succeeded"
|
|
624
|
+
? "Insert finished."
|
|
625
|
+
: "Insert running.";
|
|
626
|
+
fillEl.style.width = percent.toFixed(2) + "%";
|
|
627
|
+
progressNumbersEl.textContent = inserted.toLocaleString() + " / " + total.toLocaleString();
|
|
628
|
+
progressPercentEl.textContent = percent.toFixed(1) + "%";
|
|
629
|
+
streamNameEl.textContent = job?.stream ?? "Not started";
|
|
630
|
+
batchSizeEl.textContent = job?.batchSize ? job.batchSize.toLocaleString() + " events" : "-";
|
|
631
|
+
jobStateEl.textContent = job?.status ?? "idle";
|
|
632
|
+
errorEl.hidden = !(job?.error);
|
|
633
|
+
errorEl.textContent = job?.error ?? "";
|
|
634
|
+
|
|
635
|
+
if (job?.stream) {
|
|
636
|
+
detailsLink.hidden = false;
|
|
637
|
+
detailsLink.href = "/v1/stream/" + encodeURIComponent(job.stream) + "/_details";
|
|
638
|
+
} else {
|
|
639
|
+
detailsLink.hidden = true;
|
|
640
|
+
detailsLink.href = "#";
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const active = job != null && (job.status === "pending" || job.status === "running");
|
|
644
|
+
setButtonsDisabled(active);
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const stopPolling = () => {
|
|
648
|
+
if (pollTimer !== null) {
|
|
649
|
+
clearTimeout(pollTimer);
|
|
650
|
+
pollTimer = null;
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const schedulePoll = () => {
|
|
655
|
+
stopPolling();
|
|
656
|
+
if (!currentJobId) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
pollTimer = setTimeout(() => void pollJob(), 300);
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const pollJob = async () => {
|
|
663
|
+
if (!currentJobId) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const response = await fetch("${GENERATE_JOBS_BASE_PATH}/" + encodeURIComponent(currentJobId), {
|
|
667
|
+
cache: "no-store",
|
|
668
|
+
});
|
|
669
|
+
if (!response.ok) {
|
|
670
|
+
renderJob({
|
|
671
|
+
error: "Failed to read job status (" + response.status + " " + response.statusText + ").",
|
|
672
|
+
status: "failed",
|
|
673
|
+
});
|
|
674
|
+
currentJobId = null;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const payload = await response.json();
|
|
678
|
+
renderJob(payload.job);
|
|
679
|
+
if (payload.job && (payload.job.status === "pending" || payload.job.status === "running")) {
|
|
680
|
+
schedulePoll();
|
|
681
|
+
} else {
|
|
682
|
+
currentJobId = null;
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const startJob = async (count) => {
|
|
687
|
+
const stream = readStreamName();
|
|
688
|
+
if (!stream) {
|
|
689
|
+
renderJob({
|
|
690
|
+
error: "Enter a stream name before starting an insert.",
|
|
691
|
+
status: "failed",
|
|
692
|
+
});
|
|
693
|
+
streamInputEl.focus();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
stopPolling();
|
|
698
|
+
currentJobId = null;
|
|
699
|
+
renderJob({
|
|
700
|
+
batchSize: 0,
|
|
701
|
+
error: null,
|
|
702
|
+
inserted: 0,
|
|
703
|
+
status: "pending",
|
|
704
|
+
stream,
|
|
705
|
+
total: count,
|
|
706
|
+
});
|
|
707
|
+
const response = await fetch("${GENERATE_JOBS_BASE_PATH}", {
|
|
708
|
+
method: "POST",
|
|
709
|
+
headers: {
|
|
710
|
+
"content-type": "application/json",
|
|
711
|
+
},
|
|
712
|
+
body: JSON.stringify({ count, stream }),
|
|
713
|
+
});
|
|
714
|
+
const payload = await response.json().catch(() => ({}));
|
|
715
|
+
if (!response.ok) {
|
|
716
|
+
renderJob({
|
|
717
|
+
error: payload?.error ?? ("Failed to start job (" + response.status + " " + response.statusText + ")."),
|
|
718
|
+
status: "failed",
|
|
719
|
+
});
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
currentJobId = payload.job?.id ?? null;
|
|
723
|
+
renderJob(payload.job);
|
|
724
|
+
if (currentJobId) {
|
|
725
|
+
schedulePoll();
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
for (const button of buttons) {
|
|
730
|
+
button.addEventListener("click", () => {
|
|
731
|
+
const count = Number(button.dataset.count);
|
|
732
|
+
void startJob(count);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
renderJob(null);
|
|
737
|
+
</script>
|
|
738
|
+
</body>
|
|
739
|
+
</html>`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function createConfigPayload(bootId: string): {
|
|
743
|
+
ai: { enabled: boolean };
|
|
744
|
+
bootId: string;
|
|
745
|
+
database: { enabled: boolean };
|
|
746
|
+
streams: { url: string };
|
|
747
|
+
} {
|
|
748
|
+
return {
|
|
749
|
+
ai: { enabled: false },
|
|
750
|
+
bootId,
|
|
751
|
+
database: { enabled: false },
|
|
752
|
+
streams: { url: STUDIO_STREAMS_PROXY_BASE_PATH },
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function buildGenerateEvent(stream: string, index: number, baseMs: number): Record<string, unknown> {
|
|
757
|
+
const method = GENERATE_METHODS[index % GENERATE_METHODS.length];
|
|
758
|
+
const service = GENERATE_SERVICES[index % GENERATE_SERVICES.length];
|
|
759
|
+
const environment = GENERATE_ENVIRONMENTS[index % GENERATE_ENVIRONMENTS.length];
|
|
760
|
+
const region = GENERATE_REGIONS[index % GENERATE_REGIONS.length];
|
|
761
|
+
const path = GENERATE_PATHS[index % GENERATE_PATHS.length];
|
|
762
|
+
const timestamp = new Date(baseMs + index * 1_000).toISOString();
|
|
763
|
+
const status =
|
|
764
|
+
index % 41 === 0 ? 503 : index % 17 === 0 ? 429 : index % 7 === 0 ? 404 : 200;
|
|
765
|
+
const isError = status >= 500;
|
|
766
|
+
const isWarn = !isError && status >= 400;
|
|
767
|
+
|
|
768
|
+
const entropyHex = createEntropyHex(stream, index, 96);
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
timestamp,
|
|
772
|
+
requestId: `${stream}-req-${index.toString().padStart(8, "0")}`,
|
|
773
|
+
traceContext: {
|
|
774
|
+
traceId: `${stream}-trace-${Math.floor(index / 4)
|
|
775
|
+
.toString()
|
|
776
|
+
.padStart(6, "0")}`,
|
|
777
|
+
spanId: `${service}-span-${index.toString().padStart(8, "0")}`,
|
|
778
|
+
},
|
|
779
|
+
method,
|
|
780
|
+
path,
|
|
781
|
+
service,
|
|
782
|
+
environment,
|
|
783
|
+
version: "compute-demo-v1",
|
|
784
|
+
region,
|
|
785
|
+
status,
|
|
786
|
+
duration: 22 + (index % 900),
|
|
787
|
+
message: isError
|
|
788
|
+
? "Upstream dependency timed out"
|
|
789
|
+
: isWarn
|
|
790
|
+
? "Request rejected by policy"
|
|
791
|
+
: "Request completed",
|
|
792
|
+
why: isError
|
|
793
|
+
? "Payment provider exceeded deadline budget"
|
|
794
|
+
: isWarn
|
|
795
|
+
? "Concurrency limiter rejected this request"
|
|
796
|
+
: undefined,
|
|
797
|
+
fix: isError
|
|
798
|
+
? "Retry this operation with exponential backoff"
|
|
799
|
+
: isWarn
|
|
800
|
+
? "Reduce request rate or retry after the limiter window"
|
|
801
|
+
: undefined,
|
|
802
|
+
link:
|
|
803
|
+
status >= 400
|
|
804
|
+
? `https://example.internal/runbooks/${service}/${status}`
|
|
805
|
+
: undefined,
|
|
806
|
+
sampling: {
|
|
807
|
+
kept: true,
|
|
808
|
+
source: "compute-demo-generate",
|
|
809
|
+
},
|
|
810
|
+
tenant: `tenant-${(index % 24).toString().padStart(2, "0")}`,
|
|
811
|
+
host: `${service}-${region}-${index % 6}`,
|
|
812
|
+
releaseChannel: environment === "prod" ? "stable" : "preview",
|
|
813
|
+
context: {
|
|
814
|
+
actor: {
|
|
815
|
+
id: `user-${(index % 5_000).toString().padStart(5, "0")}`,
|
|
816
|
+
plan: index % 13 === 0 ? "enterprise" : "pro",
|
|
817
|
+
},
|
|
818
|
+
fingerprint: entropyHex,
|
|
819
|
+
request: {
|
|
820
|
+
bytes: 512 + (index % 8_192),
|
|
821
|
+
routeGroup: path.split("/")[2] ?? "root",
|
|
822
|
+
traceToken: entropyHex.slice(0, 48),
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function responseText(response: Response): Promise<string> {
|
|
829
|
+
const text = await response.text();
|
|
830
|
+
return text.trim();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function assertAllowedGenerateCount(value: unknown): number | null {
|
|
834
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
return GENERATE_BUTTON_COUNTS.has(value) ? value : null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function normalizeGenerateStreamName(value: unknown): string | null {
|
|
841
|
+
if (value == null) {
|
|
842
|
+
return DEFAULT_GENERATE_STREAM;
|
|
843
|
+
}
|
|
844
|
+
if (typeof value !== "string") {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const stream = value.trim();
|
|
849
|
+
if (
|
|
850
|
+
stream.length === 0 ||
|
|
851
|
+
stream.length > GENERATE_STREAM_NAME_MAX_LENGTH ||
|
|
852
|
+
!GENERATE_STREAM_NAME_PATTERN.test(stream)
|
|
853
|
+
) {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return stream;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function createComputeDemoSite(
|
|
861
|
+
options: CreateComputeDemoSiteOptions,
|
|
862
|
+
): ComputeDemoSite {
|
|
863
|
+
const bootId = options.bootId ?? crypto.randomUUID();
|
|
864
|
+
const jobs = new Map<string, GenerateJobState>();
|
|
865
|
+
const cleanupTimers = new Set<Timer>();
|
|
866
|
+
const studioAssets = options.studioAssets;
|
|
867
|
+
|
|
868
|
+
const clearTimer = (timer: Timer): void => {
|
|
869
|
+
cleanupTimers.delete(timer);
|
|
870
|
+
clearTimeout(timer);
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const scheduleCleanup = (jobId: string): void => {
|
|
874
|
+
const timer = setTimeout(() => {
|
|
875
|
+
jobs.delete(jobId);
|
|
876
|
+
clearTimer(timer);
|
|
877
|
+
}, JOB_RETENTION_MS);
|
|
878
|
+
(timer as { unref?: () => void }).unref?.();
|
|
879
|
+
cleanupTimers.add(timer);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const writeHtml = (request: Request, html: string): Response =>
|
|
883
|
+
textResponse(request, html, {
|
|
884
|
+
contentType: "text/html; charset=utf-8",
|
|
885
|
+
headers: {
|
|
886
|
+
"cache-control": NO_STORE_CACHE_CONTROL,
|
|
887
|
+
},
|
|
888
|
+
status: 200,
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
const writeAsset = (request: Request, body: BodyInit, contentType: string): Response =>
|
|
892
|
+
new Response(responseBodyForMethod(request, body), {
|
|
893
|
+
headers: {
|
|
894
|
+
"cache-control": APP_CACHE_CONTROL,
|
|
895
|
+
"content-type": contentType,
|
|
896
|
+
},
|
|
897
|
+
status: 200,
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const proxyRequestToStreamsApp = async (
|
|
901
|
+
request: Request,
|
|
902
|
+
url: URL,
|
|
903
|
+
): Promise<Response> => {
|
|
904
|
+
const proxyPathname = url.pathname.slice(STUDIO_STREAMS_PROXY_BASE_PATH.length);
|
|
905
|
+
const normalizedPathname =
|
|
906
|
+
proxyPathname.length > 0 ? proxyPathname : "/";
|
|
907
|
+
const upstreamUrl = new URL(
|
|
908
|
+
`${normalizedPathname}${url.search}`,
|
|
909
|
+
"http://streams.internal/",
|
|
910
|
+
);
|
|
911
|
+
const headers = new Headers(request.headers);
|
|
912
|
+
headers.delete("host");
|
|
913
|
+
const body =
|
|
914
|
+
request.method === "GET" || request.method === "HEAD"
|
|
915
|
+
? undefined
|
|
916
|
+
: await request.arrayBuffer();
|
|
917
|
+
|
|
918
|
+
const upstreamRequest = new Request(upstreamUrl, {
|
|
919
|
+
body,
|
|
920
|
+
headers,
|
|
921
|
+
method: request.method,
|
|
922
|
+
redirect: "manual",
|
|
923
|
+
signal: request.signal,
|
|
924
|
+
});
|
|
925
|
+
const response = await options.streamsApp.fetch(upstreamRequest);
|
|
926
|
+
const responseHeaders = new Headers(response.headers);
|
|
927
|
+
|
|
928
|
+
responseHeaders.set("cache-control", NO_STORE_CACHE_CONTROL);
|
|
929
|
+
|
|
930
|
+
return new Response(response.body, {
|
|
931
|
+
headers: responseHeaders,
|
|
932
|
+
status: response.status,
|
|
933
|
+
statusText: response.statusText,
|
|
934
|
+
});
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const ensureGenerateStream = async (stream: string): Promise<void> => {
|
|
938
|
+
if (options.streamsApp.ensureGenerateStream) {
|
|
939
|
+
await options.streamsApp.ensureGenerateStream(stream);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const createResponse = await options.streamsApp.fetch(
|
|
944
|
+
new Request(`http://streams.internal/v1/stream/${encodeURIComponent(stream)}`, {
|
|
945
|
+
headers: {
|
|
946
|
+
"content-type": "application/json",
|
|
947
|
+
},
|
|
948
|
+
method: "PUT",
|
|
949
|
+
}),
|
|
950
|
+
);
|
|
951
|
+
if (!createResponse.ok && createResponse.status !== 204) {
|
|
952
|
+
throw dsError(
|
|
953
|
+
`stream create failed (${createResponse.status}): ${await responseText(createResponse)}`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const profileResponse = await options.streamsApp.fetch(
|
|
958
|
+
new Request(
|
|
959
|
+
`http://streams.internal/v1/stream/${encodeURIComponent(stream)}/_profile`,
|
|
960
|
+
{
|
|
961
|
+
body: JSON.stringify({
|
|
962
|
+
apiVersion: "durable.streams/profile/v1",
|
|
963
|
+
profile: {
|
|
964
|
+
kind: "evlog",
|
|
965
|
+
},
|
|
966
|
+
}),
|
|
967
|
+
headers: {
|
|
968
|
+
"content-type": "application/json",
|
|
969
|
+
},
|
|
970
|
+
method: "POST",
|
|
971
|
+
},
|
|
972
|
+
),
|
|
973
|
+
);
|
|
974
|
+
if (!profileResponse.ok) {
|
|
975
|
+
throw dsError(
|
|
976
|
+
`profile install failed (${profileResponse.status}): ${await responseText(profileResponse)}`,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
const appendGenerateBatch = async (
|
|
982
|
+
stream: string,
|
|
983
|
+
events: Array<Record<string, unknown>>,
|
|
984
|
+
): Promise<void> => {
|
|
985
|
+
if (options.streamsApp.appendGenerateBatch) {
|
|
986
|
+
await options.streamsApp.appendGenerateBatch(stream, events);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const response = await options.streamsApp.fetch(
|
|
991
|
+
new Request(`http://streams.internal/v1/stream/${encodeURIComponent(stream)}`, {
|
|
992
|
+
body: JSON.stringify(events),
|
|
993
|
+
headers: {
|
|
994
|
+
"content-type": "application/json",
|
|
995
|
+
},
|
|
996
|
+
method: "POST",
|
|
997
|
+
}),
|
|
998
|
+
);
|
|
999
|
+
if (!response.ok && response.status !== 204) {
|
|
1000
|
+
throw dsError(
|
|
1001
|
+
`append failed (${response.status}): ${await responseText(response)}`,
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const runGenerateJob = async (job: GenerateJobState): Promise<void> => {
|
|
1007
|
+
const sampleEvent = buildGenerateEvent(job.stream, 0, Date.now());
|
|
1008
|
+
const estimatedBytes = new TextEncoder().encode(
|
|
1009
|
+
JSON.stringify(sampleEvent),
|
|
1010
|
+
).byteLength;
|
|
1011
|
+
const batchSize = clamp(
|
|
1012
|
+
Math.floor(GENERATE_BATCH_TARGET_BYTES / Math.max(estimatedBytes, 1)),
|
|
1013
|
+
GENERATE_BATCH_MIN_EVENTS,
|
|
1014
|
+
GENERATE_BATCH_MAX_EVENTS,
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
job.batchSize = batchSize;
|
|
1018
|
+
job.status = "running";
|
|
1019
|
+
|
|
1020
|
+
let beganGenerateJob = false;
|
|
1021
|
+
try {
|
|
1022
|
+
await ensureGenerateStream(job.stream);
|
|
1023
|
+
options.streamsApp.beginGenerateJob?.(job.stream);
|
|
1024
|
+
beganGenerateJob = true;
|
|
1025
|
+
const baseMs = Date.now() - Math.max(0, job.total - 1) * 1_000;
|
|
1026
|
+
|
|
1027
|
+
while (job.inserted < job.total) {
|
|
1028
|
+
const remaining = job.total - job.inserted;
|
|
1029
|
+
const nextBatchSize = Math.min(job.batchSize, remaining);
|
|
1030
|
+
const events: Array<Record<string, unknown>> = [];
|
|
1031
|
+
|
|
1032
|
+
for (let index = 0; index < nextBatchSize; index += 1) {
|
|
1033
|
+
events.push(
|
|
1034
|
+
buildGenerateEvent(job.stream, job.inserted + index, baseMs),
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
await appendGenerateBatch(job.stream, events);
|
|
1039
|
+
const previousInserted = job.inserted;
|
|
1040
|
+
job.inserted += events.length;
|
|
1041
|
+
collectGenerateBatchMemory(job, previousInserted);
|
|
1042
|
+
|
|
1043
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
job.status = "succeeded";
|
|
1047
|
+
job.finishedAt = new Date().toISOString();
|
|
1048
|
+
scheduleCleanup(job.id);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
1051
|
+
job.status = "failed";
|
|
1052
|
+
job.finishedAt = new Date().toISOString();
|
|
1053
|
+
scheduleCleanup(job.id);
|
|
1054
|
+
} finally {
|
|
1055
|
+
if (beganGenerateJob) options.streamsApp.endGenerateJob?.(job.stream);
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
const handleGenerateJobCreate = async (request: Request): Promise<Response> => {
|
|
1060
|
+
if (request.method === "OPTIONS") {
|
|
1061
|
+
return new Response(null, {
|
|
1062
|
+
headers: {
|
|
1063
|
+
Allow: "POST,OPTIONS",
|
|
1064
|
+
},
|
|
1065
|
+
status: 204,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
if (request.method !== "POST") {
|
|
1069
|
+
return methodNotAllowed("POST,OPTIONS");
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
let payload: { count?: unknown; stream?: unknown };
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
payload = (await request.json()) as { count?: unknown; stream?: unknown };
|
|
1076
|
+
} catch {
|
|
1077
|
+
return jsonResponse(400, { error: "Invalid JSON payload." });
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const count = assertAllowedGenerateCount(payload.count);
|
|
1081
|
+
if (count == null) {
|
|
1082
|
+
return jsonResponse(400, {
|
|
1083
|
+
error: "count must be one of 1000, 10000, or 100000.",
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const stream = normalizeGenerateStreamName(payload.stream);
|
|
1088
|
+
if (stream == null) {
|
|
1089
|
+
return jsonResponse(400, {
|
|
1090
|
+
error:
|
|
1091
|
+
"stream must be 1-128 characters, start with a letter or number, and contain only letters, numbers, dot, underscore, slash, or hyphen.",
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const id = crypto.randomUUID();
|
|
1096
|
+
const job: GenerateJobState = {
|
|
1097
|
+
batchSize: 0,
|
|
1098
|
+
error: null,
|
|
1099
|
+
finishedAt: null,
|
|
1100
|
+
id,
|
|
1101
|
+
inserted: 0,
|
|
1102
|
+
startedAt: new Date().toISOString(),
|
|
1103
|
+
status: "pending",
|
|
1104
|
+
stream,
|
|
1105
|
+
total: count,
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
jobs.set(id, job);
|
|
1109
|
+
void runGenerateJob(job);
|
|
1110
|
+
|
|
1111
|
+
return jsonResponse(202, { job });
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
const handleGenerateJobRead = (request: Request, url: URL): Response => {
|
|
1115
|
+
if (request.method === "OPTIONS") {
|
|
1116
|
+
return new Response(null, {
|
|
1117
|
+
headers: {
|
|
1118
|
+
Allow: "GET,OPTIONS",
|
|
1119
|
+
},
|
|
1120
|
+
status: 204,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
if (request.method !== "GET") {
|
|
1124
|
+
return methodNotAllowed("GET,OPTIONS");
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const suffix = url.pathname.slice(GENERATE_JOBS_BASE_PATH.length + 1);
|
|
1128
|
+
const jobId = decodeURIComponent(suffix);
|
|
1129
|
+
const job = jobs.get(jobId);
|
|
1130
|
+
if (!job) {
|
|
1131
|
+
return jsonResponse(404, { error: "Job not found." });
|
|
1132
|
+
}
|
|
1133
|
+
return jsonResponse(200, { job });
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
return {
|
|
1137
|
+
close(): void {
|
|
1138
|
+
for (const timer of cleanupTimers) {
|
|
1139
|
+
clearTimer(timer);
|
|
1140
|
+
}
|
|
1141
|
+
},
|
|
1142
|
+
|
|
1143
|
+
async fetch(request: Request): Promise<Response> {
|
|
1144
|
+
const url = new URL(request.url);
|
|
1145
|
+
|
|
1146
|
+
if (url.pathname === "/") {
|
|
1147
|
+
return writeHtml(request, createLandingHtmlDocument());
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (url.pathname === STUDIO_ROOT_PATH || url.pathname === `${STUDIO_ROOT_PATH}/`) {
|
|
1151
|
+
return writeHtml(request, createStudioHtmlDocument());
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (url.pathname === `${STUDIO_ROOT_PATH}/app.js`) {
|
|
1155
|
+
return writeAsset(
|
|
1156
|
+
request,
|
|
1157
|
+
studioAssets.appScript,
|
|
1158
|
+
"application/javascript; charset=utf-8",
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (url.pathname === `${STUDIO_ROOT_PATH}/app.css`) {
|
|
1163
|
+
return writeAsset(request, studioAssets.appStyles, "text/css; charset=utf-8");
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (
|
|
1167
|
+
url.pathname === STUDIO_STREAMS_PROXY_BASE_PATH ||
|
|
1168
|
+
url.pathname.startsWith(`${STUDIO_STREAMS_PROXY_BASE_PATH}/`)
|
|
1169
|
+
) {
|
|
1170
|
+
return await proxyRequestToStreamsApp(request, url);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (url.pathname === STUDIO_CONFIG_PATH) {
|
|
1174
|
+
return jsonResponse(200, createConfigPayload(bootId));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (url.pathname === STUDIO_QUERY_PATH) {
|
|
1178
|
+
if (request.method === "OPTIONS") {
|
|
1179
|
+
return new Response(null, {
|
|
1180
|
+
headers: {
|
|
1181
|
+
Allow: "POST,OPTIONS",
|
|
1182
|
+
},
|
|
1183
|
+
status: 204,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
if (request.method !== "POST") {
|
|
1187
|
+
return methodNotAllowed("POST,OPTIONS");
|
|
1188
|
+
}
|
|
1189
|
+
return jsonResponse(503, {
|
|
1190
|
+
error: "Database access is disabled for this deployment.",
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (url.pathname === STUDIO_AI_PATH) {
|
|
1195
|
+
if (request.method === "OPTIONS") {
|
|
1196
|
+
return new Response(null, {
|
|
1197
|
+
headers: {
|
|
1198
|
+
Allow: "POST,OPTIONS",
|
|
1199
|
+
},
|
|
1200
|
+
status: 204,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
if (request.method !== "POST") {
|
|
1204
|
+
return methodNotAllowed("POST,OPTIONS");
|
|
1205
|
+
}
|
|
1206
|
+
return jsonResponse(503, {
|
|
1207
|
+
code: "llm_disabled",
|
|
1208
|
+
message: "AI is disabled for this deployment.",
|
|
1209
|
+
ok: false,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (url.pathname === GENERATE_ROOT_PATH || url.pathname === `${GENERATE_ROOT_PATH}/`) {
|
|
1214
|
+
return writeHtml(request, createGenerateHtmlDocument());
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (url.pathname === GENERATE_JOBS_BASE_PATH) {
|
|
1218
|
+
return await handleGenerateJobCreate(request);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (url.pathname.startsWith(`${GENERATE_JOBS_BASE_PATH}/`)) {
|
|
1222
|
+
return handleGenerateJobRead(request, url);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
for (const assetPath of normalizeBuiltAssetPath(url.pathname)) {
|
|
1226
|
+
const asset = studioAssets.builtAssets.get(assetPath);
|
|
1227
|
+
if (asset) {
|
|
1228
|
+
const assetBytes = asset.bytes instanceof Uint8Array
|
|
1229
|
+
? new Uint8Array(asset.bytes)
|
|
1230
|
+
: new Uint8Array(asset.bytes);
|
|
1231
|
+
return writeAsset(
|
|
1232
|
+
request,
|
|
1233
|
+
new Blob([assetBytes]),
|
|
1234
|
+
asset.contentType,
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
return await options.streamsApp.fetch(request);
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
}
|