void 0.7.2 → 0.7.4

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.
@@ -0,0 +1,409 @@
1
+ import { i as getRawRuntimeEnv } from "../env-raw-DtfQ9E31.mjs";
2
+ import { getUser } from "./auth.mjs";
3
+ //#region src/runtime/live.ts
4
+ let liveBindingName = "VOID_LIVE";
5
+ const registeredStreamIds = /* @__PURE__ */ new Set();
6
+ const DEFAULT_LIMITS = {
7
+ maxSubscriptionsPerConnection: 256,
8
+ maxSubscriptionsPerTopic: 256,
9
+ maxOperationsPerControlRequest: 100,
10
+ maxQueuedEventsPerConnection: 100,
11
+ maxEncodedEventSize: 64 * 1024,
12
+ deliveryAttemptTimeoutMs: 1500
13
+ };
14
+ const MAX_SUBSCRIPTIONS_PER_TOPIC = 256;
15
+ function __setLiveBindingName(name) {
16
+ liveBindingName = assertBindingName(name);
17
+ }
18
+ function __getLiveBindingName() {
19
+ return liveBindingName;
20
+ }
21
+ function defineLiveStream(options) {
22
+ const streamId = assertStreamId(options.id);
23
+ const limits = resolveLiveLimits(options.limits);
24
+ if (registeredStreamIds.has(streamId)) throw new Error(`live: duplicate stream id "${streamId}".`);
25
+ registeredStreamIds.add(streamId);
26
+ async function resolveOwner(ctx, request, connectOptions) {
27
+ const resolvedUser = connectOptions && "user" in connectOptions ? connectOptions.user : getContextUser(ctx) ?? getUser();
28
+ const hookContext = {
29
+ request,
30
+ env: ctx.env,
31
+ user: resolvedUser ?? null,
32
+ owner: connectOptions?.owner ?? null
33
+ };
34
+ const owner = (connectOptions && "owner" in connectOptions ? connectOptions.owner : await options.identifyConnection?.(hookContext)) ?? defaultOwner(resolvedUser);
35
+ if (owner == null && !options.allowAnonymousControl) throw liveResponseError("FORBIDDEN", "live: anonymous control is not enabled.", 403);
36
+ assertOwner(owner);
37
+ return {
38
+ owner,
39
+ user: resolvedUser ?? null
40
+ };
41
+ }
42
+ async function publishWithEnv(env, topic, data, publishOptions = {}) {
43
+ const binding = getLiveBinding(env);
44
+ assertTopic(topic);
45
+ assertOptionalString("type", publishOptions.type);
46
+ assertOptionalString("eventId", publishOptions.eventId);
47
+ assertJsonSerializable(data);
48
+ const topicKey = await topicDigest(streamId, topic);
49
+ const id = binding.idFromName(topicInstanceName(streamId, topicKey));
50
+ const response = await binding.get(id).fetch(internalUrl("/publish"), {
51
+ method: "POST",
52
+ body: JSON.stringify({
53
+ streamId,
54
+ topic,
55
+ topicKey,
56
+ data,
57
+ type: publishOptions.type,
58
+ eventId: publishOptions.eventId,
59
+ limits
60
+ })
61
+ });
62
+ if (!response.ok) {
63
+ const body = await response.text();
64
+ const parsed = parseLiveControlResponse(body);
65
+ if (parsed && !parsed.accepted) throw new Error(`${parsed.error.code}: ${parsed.error.message}`);
66
+ throw new Error(body || response.statusText);
67
+ }
68
+ }
69
+ return {
70
+ async connect(ctx, connectOptions) {
71
+ try {
72
+ const request = ctx.req.raw;
73
+ const connectionId = getConnectionId(request);
74
+ const { owner, user } = await resolveOwner(ctx, request, connectOptions);
75
+ const decision = await options.onConnect?.({
76
+ request,
77
+ env: ctx.env,
78
+ user,
79
+ owner,
80
+ connection: { id: connectionId }
81
+ });
82
+ if (decision instanceof Response) return decision;
83
+ const binding = getLiveBinding(ctx.env);
84
+ const id = binding.idFromName(connectionInstanceName(streamId, connectionId));
85
+ return binding.get(id).fetch(internalUrl("/connect"), {
86
+ method: "POST",
87
+ body: JSON.stringify({
88
+ streamId,
89
+ connectionId,
90
+ owner,
91
+ limits
92
+ })
93
+ });
94
+ } catch (error) {
95
+ return errorToResponse(error);
96
+ }
97
+ },
98
+ async control(ctx, controlOptions) {
99
+ try {
100
+ const request = ctx.req.raw;
101
+ const body = await readControlRequest(request, limits);
102
+ const { owner, user } = await resolveOwner(ctx, request, controlOptions);
103
+ const authorized = [];
104
+ const results = [];
105
+ for (const entry of body.operations) {
106
+ if (!entry.ok) {
107
+ results[entry.index] = entry.result;
108
+ continue;
109
+ }
110
+ const { operation, index } = entry;
111
+ if (operation.kind === "unsubscribe") {
112
+ authorized.push({
113
+ operation,
114
+ index
115
+ });
116
+ continue;
117
+ }
118
+ const decision = await options.onSubscribe?.({
119
+ request,
120
+ env: ctx.env,
121
+ user,
122
+ owner,
123
+ connection: { id: body.connectionId },
124
+ subscription: { id: operation.id },
125
+ topic: operation.topic,
126
+ lastEventId: operation.lastEventId
127
+ });
128
+ if (decision instanceof Response) {
129
+ results[index] = {
130
+ ok: false,
131
+ kind: "subscribe",
132
+ id: operation.id,
133
+ error: await responseToControlError(decision)
134
+ };
135
+ continue;
136
+ }
137
+ authorized.push({
138
+ operation,
139
+ index
140
+ });
141
+ }
142
+ if (authorized.length > 0) {
143
+ const binding = getLiveBinding(ctx.env);
144
+ const id = binding.idFromName(connectionInstanceName(streamId, body.connectionId));
145
+ const response = await binding.get(id).fetch(internalUrl("/control"), {
146
+ method: "POST",
147
+ body: JSON.stringify({
148
+ streamId,
149
+ connectionId: body.connectionId,
150
+ owner,
151
+ operations: authorized.map((entry) => entry.operation),
152
+ limits
153
+ })
154
+ });
155
+ const controlResponse = await response.json();
156
+ if (!controlResponse.accepted) return Response.json(controlResponse, { status: response.status });
157
+ for (const [resultIndex, result] of controlResponse.results.entries()) {
158
+ const operation = authorized[resultIndex];
159
+ if (operation) results[operation.index] = result;
160
+ }
161
+ }
162
+ for (const entry of body.operations) {
163
+ if (!entry.ok) continue;
164
+ const { operation, index } = entry;
165
+ results[index] ??= {
166
+ ok: false,
167
+ kind: operation.kind,
168
+ id: operation.id,
169
+ error: {
170
+ code: "INTERNAL_ERROR",
171
+ message: "live: missing control result."
172
+ }
173
+ };
174
+ }
175
+ return Response.json({
176
+ accepted: true,
177
+ connectionId: body.connectionId,
178
+ results
179
+ });
180
+ } catch (error) {
181
+ return errorToResponse(error);
182
+ }
183
+ },
184
+ publish(topic, data, publishOptions) {
185
+ let env;
186
+ try {
187
+ env = getRawRuntimeEnv();
188
+ } catch {
189
+ throw new Error("live: publish() was called outside a Void server runtime context. Pass an explicit env with stream.withEnv(env).publish(...).");
190
+ }
191
+ return publishWithEnv(env, topic, data, publishOptions);
192
+ },
193
+ withEnv(env) {
194
+ return { publish(topic, data, publishOptions) {
195
+ return publishWithEnv(env, topic, data, publishOptions);
196
+ } };
197
+ }
198
+ };
199
+ }
200
+ function defaultOwner(user) {
201
+ if (user && typeof user === "object" && "id" in user && typeof user.id === "string") return `user:${user.id}`;
202
+ return null;
203
+ }
204
+ function getContextUser(ctx) {
205
+ try {
206
+ return ctx.get("user");
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+ function parseLiveControlResponse(body) {
212
+ try {
213
+ const parsed = JSON.parse(body);
214
+ return typeof parsed === "object" && parsed !== null && "accepted" in parsed ? parsed : null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+ async function readControlRequest(request, limits) {
220
+ let body;
221
+ try {
222
+ body = await request.json();
223
+ } catch {
224
+ throw liveResponseError("BAD_REQUEST", "live: control request body must be JSON.", 400);
225
+ }
226
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw liveResponseError("BAD_REQUEST", "live: control request body must be an object.", 400);
227
+ const record = body;
228
+ const connectionId = assertConnectionId(record.connectionId);
229
+ if (!Array.isArray(record.operations)) throw liveResponseError("BAD_REQUEST", "live: operations must be an array.", 400);
230
+ if (record.operations.length > limits.maxOperationsPerControlRequest) throw liveResponseError("BAD_REQUEST", "live: too many operations in one control request.", 400);
231
+ return {
232
+ connectionId,
233
+ operations: record.operations.map(parseOperation)
234
+ };
235
+ }
236
+ function parseOperation(value, index) {
237
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw liveResponseError("BAD_REQUEST", "live: operation must be an object.", 400);
238
+ const record = value;
239
+ if (record.kind === "subscribe") {
240
+ const id = parseBoundedString(record.id, 1, 256);
241
+ if (id == null) return invalidOperation(index, "subscribe", record.id, "INVALID_SUBSCRIPTION_ID");
242
+ const topic = parseBoundedString(record.topic, 1, 512);
243
+ if (topic == null) return invalidOperation(index, "subscribe", id, "INVALID_TOPIC");
244
+ let lastEventId;
245
+ if (record.lastEventId !== void 0) {
246
+ lastEventId = parseBoundedString(record.lastEventId, 0, 512) ?? void 0;
247
+ if (lastEventId === void 0) return invalidOperation(index, "subscribe", id, "BAD_REQUEST");
248
+ }
249
+ return {
250
+ ok: true,
251
+ index,
252
+ operation: {
253
+ kind: "subscribe",
254
+ id,
255
+ topic,
256
+ ...lastEventId !== void 0 && { lastEventId }
257
+ }
258
+ };
259
+ }
260
+ if (record.kind === "unsubscribe") {
261
+ const id = parseBoundedString(record.id, 1, 256);
262
+ if (id == null) return invalidOperation(index, "unsubscribe", record.id, "INVALID_SUBSCRIPTION_ID");
263
+ return {
264
+ ok: true,
265
+ index,
266
+ operation: {
267
+ kind: "unsubscribe",
268
+ id
269
+ }
270
+ };
271
+ }
272
+ throw liveResponseError("BAD_REQUEST", "live: operation kind is invalid.", 400);
273
+ }
274
+ function invalidOperation(index, kind, id, code) {
275
+ return {
276
+ ok: false,
277
+ index,
278
+ result: {
279
+ ok: false,
280
+ kind,
281
+ id: typeof id === "string" ? id : "",
282
+ error: {
283
+ code,
284
+ message: {
285
+ BAD_REQUEST: "live: invalid control operation.",
286
+ CONNECTION_CLOSED: "live: connection closed.",
287
+ CONNECTION_NOT_FOUND: "live: connection not found.",
288
+ FORBIDDEN: "Forbidden",
289
+ INVALID_SUBSCRIPTION_ID: "live: invalid subscription id.",
290
+ INVALID_TOPIC: "live: invalid topic.",
291
+ OWNER_MISMATCH: "live: owner mismatch.",
292
+ SUBSCRIPTION_LIMIT: "live: subscription limit reached.",
293
+ TOPIC_FULL: "live: topic subscription limit reached.",
294
+ PAYLOAD_TOO_LARGE: "live: payload too large.",
295
+ INTERNAL_ERROR: "live: internal error."
296
+ }[code]
297
+ }
298
+ }
299
+ };
300
+ }
301
+ function getLiveBinding(env) {
302
+ if (typeof env !== "object" || env === null) throw new Error("live: Cloudflare env is unavailable.");
303
+ const binding = env[liveBindingName];
304
+ if (!binding) throw new Error(`live: Missing Durable Object binding "${liveBindingName}".`);
305
+ return binding;
306
+ }
307
+ function getConnectionId(request) {
308
+ return assertConnectionId(new URL(request.url).searchParams.get("connectionId"));
309
+ }
310
+ function assertStreamId(value) {
311
+ return assertBoundedString("stream id", value, 1, 128);
312
+ }
313
+ function assertBindingName(value) {
314
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new Error(`live: invalid Durable Object binding name "${value}".`);
315
+ return value;
316
+ }
317
+ function assertConnectionId(value) {
318
+ return assertBoundedString("connectionId", value, 16, 256);
319
+ }
320
+ function assertTopic(value) {
321
+ return assertBoundedString("topic", value, 1, 512);
322
+ }
323
+ function assertOwner(value) {
324
+ if (value !== null) assertBoundedString("owner", value, 1, 512);
325
+ }
326
+ function assertOptionalString(name, value) {
327
+ if (value !== void 0) assertBoundedString(name, value, 1, 512);
328
+ }
329
+ function resolveLiveLimits(input) {
330
+ const limits = { ...DEFAULT_LIMITS };
331
+ for (const key of Object.keys(limits)) {
332
+ const value = input?.[key];
333
+ if (value === void 0) continue;
334
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) throw new Error(`live: limits.${key} must be a positive finite integer.`);
335
+ limits[key] = value;
336
+ }
337
+ if (limits.maxSubscriptionsPerTopic > MAX_SUBSCRIPTIONS_PER_TOPIC) throw new Error(`live: limits.maxSubscriptionsPerTopic must be at most ${MAX_SUBSCRIPTIONS_PER_TOPIC}.`);
338
+ return limits;
339
+ }
340
+ function assertBoundedString(name, value, min, max) {
341
+ const parsed = parseBoundedString(value, min, max);
342
+ if (parsed == null) throw liveResponseError("BAD_REQUEST", `live: invalid ${name}.`, 400);
343
+ return parsed;
344
+ }
345
+ function parseBoundedString(value, min, max) {
346
+ if (typeof value !== "string" || value.length < min || value.length > max || /[\r\n]/.test(value) || value.includes("\0")) return null;
347
+ return value;
348
+ }
349
+ function assertJsonSerializable(value) {
350
+ try {
351
+ if (typeof JSON.stringify(value) !== "string") throw new Error("unsupported value");
352
+ } catch (error) {
353
+ throw new Error(`live: payload must be JSON-serializable. ${error instanceof Error ? error.message : String(error)}`);
354
+ }
355
+ }
356
+ async function topicDigest(streamId, topic) {
357
+ const bytes = new TextEncoder().encode(`${streamId}\u0000${topic}`);
358
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
359
+ const binary = String.fromCharCode(...new Uint8Array(digest));
360
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
361
+ }
362
+ function connectionInstanceName(streamId, connectionId) {
363
+ return `live:${streamId}:connection:${connectionId}`;
364
+ }
365
+ function topicInstanceName(streamId, topicKey) {
366
+ return `live:${streamId}:topic:${topicKey}`;
367
+ }
368
+ function internalUrl(path) {
369
+ return `https://void.live${path}`;
370
+ }
371
+ var LiveResponseError = class extends Error {
372
+ constructor(code, message, status) {
373
+ super(message);
374
+ this.code = code;
375
+ this.status = status;
376
+ }
377
+ };
378
+ function liveResponseError(code, message, status) {
379
+ throw new LiveResponseError(code, message, status);
380
+ }
381
+ function errorToResponse(error) {
382
+ if (error instanceof LiveResponseError) return Response.json({
383
+ accepted: false,
384
+ error: {
385
+ code: error.code,
386
+ message: error.message
387
+ }
388
+ }, { status: error.status });
389
+ return Response.json({
390
+ accepted: false,
391
+ error: {
392
+ code: "INTERNAL_ERROR",
393
+ message: error instanceof Error ? error.message : String(error)
394
+ }
395
+ }, { status: 500 });
396
+ }
397
+ async function responseToControlError(response) {
398
+ const message = await response.text().catch(() => "") || response.statusText;
399
+ if (response.status === 403 || response.status === 401) return {
400
+ code: "FORBIDDEN",
401
+ message: message || "Forbidden"
402
+ };
403
+ return {
404
+ code: "BAD_REQUEST",
405
+ message: message || "Subscription rejected"
406
+ };
407
+ }
408
+ //#endregion
409
+ export { __getLiveBindingName, __setLiveBindingName, defineLiveStream };
@@ -62,7 +62,9 @@ async function runD1Migrations(db, migrations) {
62
62
  }
63
63
  const start = Date.now();
64
64
  try {
65
- await db.exec(m.sql);
65
+ const statements = splitMigrationSql(m.sql);
66
+ if (statements.length === 1 && statements[0] === m.sql) await db.exec(m.sql);
67
+ else for (const statement of statements) await db.prepare(statement).run();
66
68
  await db.prepare("INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)").bind(m.name, (/* @__PURE__ */ new Date()).toISOString()).run();
67
69
  migrationResults.push({
68
70
  name: m.name,
@@ -88,5 +90,36 @@ async function runD1Migrations(db, migrations) {
88
90
  migrations: migrationResults
89
91
  };
90
92
  }
93
+ function splitMigrationSql(sql) {
94
+ const lines = sql.split(/\r?\n/);
95
+ if (!lines.some((line) => isStatementBreakpointLine(line) || hasInlineStatementBreakpoint(line))) return [sql];
96
+ const statements = [];
97
+ const current = [];
98
+ for (const line of lines) {
99
+ if (isStatementBreakpointLine(line)) {
100
+ const statement = current.join("\n").trim();
101
+ if (statement) statements.push(statement);
102
+ current.length = 0;
103
+ continue;
104
+ }
105
+ if (hasInlineStatementBreakpoint(line)) {
106
+ current.push(line.replace(/-->\s*statement-breakpoint\s*$/, ""));
107
+ const statement = current.join("\n").trim();
108
+ if (statement) statements.push(statement);
109
+ current.length = 0;
110
+ continue;
111
+ }
112
+ current.push(line);
113
+ }
114
+ const statement = current.join("\n").trim();
115
+ if (statement) statements.push(statement);
116
+ return statements.length > 0 ? statements : [sql];
117
+ }
118
+ function isStatementBreakpointLine(line) {
119
+ return line.trim() === "--> statement-breakpoint";
120
+ }
121
+ function hasInlineStatementBreakpoint(line) {
122
+ return /;\s*-->\s*statement-breakpoint\s*$/.test(line.trim());
123
+ }
91
124
  //#endregion
92
125
  export { createMigrationHandler };
@@ -0,0 +1,22 @@
1
+ //#region src/runtime/sse-client.d.ts
2
+ type SseClientEvent<T = unknown> = {
3
+ data: T;
4
+ event: string;
5
+ id: string;
6
+ raw: MessageEvent;
7
+ };
8
+ type SseEventMap = Record<string, unknown>;
9
+ type SseClient<Events extends SseEventMap = SseEventMap> = {
10
+ on<Name extends Extract<keyof Events, string>>(event: Name, handler: (event: SseClientEvent<Events[Name]>) => void): () => void;
11
+ on<T = unknown>(event: string, handler: (event: SseClientEvent<T>) => void): () => void;
12
+ onError(handler: (event: Event) => void): () => void;
13
+ close(): void;
14
+ };
15
+ type ConnectEventStreamOptions = {
16
+ withCredentials?: boolean;
17
+ events?: Array<string>;
18
+ parse?: "json" | "text" | ((data: string) => unknown);
19
+ };
20
+ declare function connectEventStream<Events extends SseEventMap = SseEventMap>(url: string | URL, options?: ConnectEventStreamOptions): SseClient<Events>;
21
+ //#endregion
22
+ export { ConnectEventStreamOptions, SseClient, SseClientEvent, SseEventMap, connectEventStream };
@@ -0,0 +1,36 @@
1
+ //#region src/runtime/sse-client.ts
2
+ function connectEventStream(url, options = {}) {
3
+ const EventSourceCtor = globalThis.EventSource;
4
+ if (!EventSourceCtor) throw new Error("sse: EventSource is not available in this runtime.");
5
+ const source = new EventSourceCtor(String(url), { withCredentials: options.withCredentials });
6
+ const parse = getParser(options.parse);
7
+ return {
8
+ on(event, handler) {
9
+ const listener = (raw) => {
10
+ const message = raw;
11
+ handler({
12
+ data: parse(String(message.data)),
13
+ event,
14
+ id: message.lastEventId,
15
+ raw: message
16
+ });
17
+ };
18
+ source.addEventListener(event, listener);
19
+ return () => source.removeEventListener(event, listener);
20
+ },
21
+ onError(handler) {
22
+ source.addEventListener("error", handler);
23
+ return () => source.removeEventListener("error", handler);
24
+ },
25
+ close() {
26
+ source.close();
27
+ }
28
+ };
29
+ }
30
+ function getParser(parse) {
31
+ if (typeof parse === "function") return parse;
32
+ if (parse === "text") return (data) => data;
33
+ return (data) => JSON.parse(data);
34
+ }
35
+ //#endregion
36
+ export { connectEventStream };
@@ -0,0 +1,35 @@
1
+ //#region src/runtime/sse.d.ts
2
+ type SseMessage = {
3
+ data?: unknown;
4
+ event?: string;
5
+ id?: string;
6
+ retry?: number;
7
+ };
8
+ type SseTextMessage = Omit<SseMessage, "data"> & {
9
+ data?: string;
10
+ };
11
+ type SseStream = {
12
+ readonly signal: AbortSignal;
13
+ readonly closed: Promise<void>;
14
+ send(message: SseMessage): Promise<void>;
15
+ comment(text?: string): Promise<void>;
16
+ close(): Promise<void>;
17
+ };
18
+ type SseKeepAliveOptions = boolean | {
19
+ intervalMs?: number;
20
+ comment?: string;
21
+ };
22
+ type EventStreamOptions = {
23
+ headers?: HeadersInit;
24
+ keepAlive?: SseKeepAliveOptions;
25
+ signal?: AbortSignal;
26
+ };
27
+ declare class SseStreamClosedError extends Error {
28
+ constructor(message?: string);
29
+ }
30
+ declare function formatSse(message: SseMessage): string;
31
+ declare function formatSseText(message: SseTextMessage): string;
32
+ declare function getLastEventId(request: Request): string | null;
33
+ declare function eventStream(start: (stream: SseStream) => void | Promise<void>, options?: EventStreamOptions): Response;
34
+ //#endregion
35
+ export { EventStreamOptions, SseKeepAliveOptions, SseMessage, SseStream, SseStreamClosedError, SseTextMessage, eventStream, formatSse, formatSseText, getLastEventId };