appflare 0.0.28 → 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.
Files changed (141) hide show
  1. package/cli/commands/index.ts +140 -0
  2. package/cli/generate.ts +149 -0
  3. package/cli/index.ts +56 -447
  4. package/cli/load-config.ts +182 -0
  5. package/cli/schema-compiler.ts +657 -0
  6. package/cli/templates/auth/README.md +156 -0
  7. package/cli/templates/auth/config.ts +61 -0
  8. package/cli/templates/auth/route-config.ts +18 -0
  9. package/cli/templates/auth/route-handler.ts +18 -0
  10. package/cli/templates/auth/route-request-utils.ts +55 -0
  11. package/cli/templates/auth/route.ts +14 -0
  12. package/cli/templates/core/README.md +266 -0
  13. package/cli/templates/core/app-creation.ts +19 -0
  14. package/cli/templates/core/client/appflare.ts +37 -0
  15. package/cli/templates/core/client/index.ts +6 -0
  16. package/cli/templates/core/client/storage.ts +100 -0
  17. package/cli/templates/core/client/types.ts +54 -0
  18. package/cli/templates/core/client-modules/appflare.ts +112 -0
  19. package/cli/templates/core/client-modules/handlers/index.ts +740 -0
  20. package/cli/templates/core/client-modules/handlers.ts +1 -0
  21. package/cli/templates/core/client-modules/index.ts +7 -0
  22. package/cli/templates/core/client-modules/storage.ts +180 -0
  23. package/cli/templates/core/client-modules/types.ts +145 -0
  24. package/cli/templates/core/client.ts +39 -0
  25. package/cli/templates/core/drizzle.ts +15 -0
  26. package/cli/templates/core/export.ts +14 -0
  27. package/cli/templates/core/handlers-route.ts +23 -0
  28. package/cli/templates/core/handlers.ts +1 -0
  29. package/cli/templates/core/imports.ts +8 -0
  30. package/cli/templates/core/server.ts +38 -0
  31. package/cli/templates/core/types.ts +6 -0
  32. package/cli/templates/core/wrangler.ts +109 -0
  33. package/cli/templates/handlers/README.md +265 -0
  34. package/cli/templates/handlers/auth.ts +36 -0
  35. package/cli/templates/handlers/execution.ts +39 -0
  36. package/cli/templates/handlers/generators/context/context-creation.ts +80 -0
  37. package/cli/templates/handlers/generators/context/error-helpers.ts +11 -0
  38. package/cli/templates/handlers/generators/context/scheduler.ts +24 -0
  39. package/cli/templates/handlers/generators/context/storage-api.ts +112 -0
  40. package/cli/templates/handlers/generators/context/storage-helpers.ts +59 -0
  41. package/cli/templates/handlers/generators/context/types.ts +18 -0
  42. package/cli/templates/handlers/generators/context.ts +43 -0
  43. package/cli/templates/handlers/generators/execution.ts +15 -0
  44. package/cli/templates/handlers/generators/handlers.ts +13 -0
  45. package/cli/templates/handlers/index.ts +43 -0
  46. package/cli/templates/handlers/operations.ts +116 -0
  47. package/cli/templates/handlers/registration.ts +1114 -0
  48. package/cli/templates/handlers/types.ts +960 -0
  49. package/cli/templates/handlers/utils.ts +48 -0
  50. package/cli/types.ts +108 -0
  51. package/cli/utils/handler-discovery.ts +366 -0
  52. package/cli/utils/json-utils.ts +24 -0
  53. package/cli/utils/path-utils.ts +19 -0
  54. package/cli/utils/schema-discovery.ts +390 -0
  55. package/index.ts +27 -4
  56. package/package.json +23 -20
  57. package/react/index.ts +5 -3
  58. package/react/use-infinite-query.ts +190 -0
  59. package/react/use-mutation.ts +54 -0
  60. package/react/use-query.ts +158 -0
  61. package/schema.ts +262 -0
  62. package/tsconfig.json +2 -4
  63. package/cli/README.md +0 -108
  64. package/cli/core/build.ts +0 -187
  65. package/cli/core/config.ts +0 -92
  66. package/cli/core/discover-handlers.ts +0 -143
  67. package/cli/core/handlers.ts +0 -7
  68. package/cli/core/index.ts +0 -205
  69. package/cli/generators/generate-api-client/client.ts +0 -163
  70. package/cli/generators/generate-api-client/extract-configuration.ts +0 -121
  71. package/cli/generators/generate-api-client/index.ts +0 -973
  72. package/cli/generators/generate-api-client/types.ts +0 -164
  73. package/cli/generators/generate-api-client/utils.ts +0 -22
  74. package/cli/generators/generate-api-client.ts +0 -1
  75. package/cli/generators/generate-cloudflare-worker/helpers.ts +0 -24
  76. package/cli/generators/generate-cloudflare-worker/index.ts +0 -2
  77. package/cli/generators/generate-cloudflare-worker/worker.ts +0 -148
  78. package/cli/generators/generate-cloudflare-worker/wrangler.ts +0 -108
  79. package/cli/generators/generate-cloudflare-worker.ts +0 -4
  80. package/cli/generators/generate-cron-handlers/cron-handlers-block.ts +0 -2
  81. package/cli/generators/generate-cron-handlers/handler-entries.ts +0 -29
  82. package/cli/generators/generate-cron-handlers/index.ts +0 -61
  83. package/cli/generators/generate-cron-handlers/runtime-block.ts +0 -49
  84. package/cli/generators/generate-cron-handlers/type-helpers-block.ts +0 -60
  85. package/cli/generators/generate-db-handlers/index.ts +0 -33
  86. package/cli/generators/generate-db-handlers/prepare.ts +0 -24
  87. package/cli/generators/generate-db-handlers/templates.ts +0 -189
  88. package/cli/generators/generate-db-handlers.ts +0 -1
  89. package/cli/generators/generate-hono-server/auth.ts +0 -97
  90. package/cli/generators/generate-hono-server/imports.ts +0 -55
  91. package/cli/generators/generate-hono-server/index.ts +0 -52
  92. package/cli/generators/generate-hono-server/routes.ts +0 -115
  93. package/cli/generators/generate-hono-server/template.ts +0 -371
  94. package/cli/generators/generate-hono-server.ts +0 -1
  95. package/cli/generators/generate-scheduler-handlers/constants.ts +0 -8
  96. package/cli/generators/generate-scheduler-handlers/handler-entries.ts +0 -22
  97. package/cli/generators/generate-scheduler-handlers/index.ts +0 -51
  98. package/cli/generators/generate-scheduler-handlers/runtime-block.ts +0 -68
  99. package/cli/generators/generate-scheduler-handlers/scheduler-handlers-block.ts +0 -2
  100. package/cli/generators/generate-scheduler-handlers/type-helpers-block.ts +0 -68
  101. package/cli/generators/generate-scheduler-handlers.ts +0 -1
  102. package/cli/generators/generate-websocket-durable-object/auth.ts +0 -30
  103. package/cli/generators/generate-websocket-durable-object/imports.ts +0 -55
  104. package/cli/generators/generate-websocket-durable-object/index.ts +0 -41
  105. package/cli/generators/generate-websocket-durable-object/query-handlers.ts +0 -18
  106. package/cli/generators/generate-websocket-durable-object/template.ts +0 -714
  107. package/cli/generators/generate-websocket-durable-object.ts +0 -1
  108. package/cli/schema/schema-static-types.ts +0 -702
  109. package/cli/schema/schema.ts +0 -151
  110. package/cli/utils/tsc.ts +0 -54
  111. package/cli/utils/utils.ts +0 -190
  112. package/cli/utils/zod-utils.ts +0 -121
  113. package/lib/README.md +0 -50
  114. package/lib/db.ts +0 -19
  115. package/lib/location.ts +0 -110
  116. package/lib/values.ts +0 -27
  117. package/react/README.md +0 -67
  118. package/react/hooks/useMutation.ts +0 -89
  119. package/react/hooks/usePaginatedQuery.ts +0 -213
  120. package/react/hooks/useQuery.ts +0 -106
  121. package/react/shared/queryShared.ts +0 -174
  122. package/server/README.md +0 -218
  123. package/server/auth.ts +0 -107
  124. package/server/database/builders.ts +0 -83
  125. package/server/database/context.ts +0 -327
  126. package/server/database/populate.ts +0 -234
  127. package/server/database/query-builder.ts +0 -161
  128. package/server/database/query-utils.ts +0 -25
  129. package/server/db.ts +0 -2
  130. package/server/storage/auth.ts +0 -16
  131. package/server/storage/bucket.ts +0 -22
  132. package/server/storage/context.ts +0 -34
  133. package/server/storage/index.ts +0 -38
  134. package/server/storage/operations.ts +0 -149
  135. package/server/storage/route-handler.ts +0 -60
  136. package/server/storage/types.ts +0 -55
  137. package/server/storage/utils.ts +0 -47
  138. package/server/storage.ts +0 -6
  139. package/server/types/schema-refs.ts +0 -66
  140. package/server/types/types.ts +0 -633
  141. package/server/utils/id-utils.ts +0 -230
@@ -0,0 +1,1114 @@
1
+ import type { DiscoveredHandlerOperation } from "../../utils/handler-discovery";
2
+
3
+ function toSafeIdentifier(value: string): string {
4
+ const sanitized = value.replace(/[^A-Za-z0-9_]/g, "_");
5
+ if (/^[0-9]/.test(sanitized)) {
6
+ return `_${sanitized}`;
7
+ }
8
+ return sanitized;
9
+ }
10
+
11
+ function buildImportAlias(
12
+ operation: DiscoveredHandlerOperation,
13
+ index: number,
14
+ ): string {
15
+ const routeName = operation.routePath.replace(/^\//, "").replace(/\//g, "_");
16
+ return toSafeIdentifier(`op_${index}_${routeName}`);
17
+ }
18
+
19
+ export function generateRegistration(
20
+ operations: DiscoveredHandlerOperation[],
21
+ ): string {
22
+ const httpOperations = operations.filter(
23
+ (operation) => operation.kind === "query" || operation.kind === "mutation",
24
+ );
25
+ const schedulerOperations = operations.filter(
26
+ (operation) => operation.kind === "scheduler",
27
+ );
28
+ const cronOperations = operations.filter(
29
+ (operation) => operation.kind === "cron",
30
+ );
31
+ const storageOperations = operations.filter(
32
+ (operation) => operation.kind === "storage",
33
+ );
34
+
35
+ const imports = operations
36
+ .map((operation, index) => {
37
+ const alias = buildImportAlias(operation, index);
38
+ return `import { ${operation.exportName} as ${alias} } from "${operation.importPath}";`;
39
+ })
40
+ .join("\n");
41
+
42
+ const operationSchemas = httpOperations
43
+ .map((operation, index) => {
44
+ const operationIndex = operations.indexOf(operation);
45
+ const alias = buildImportAlias(operation, operationIndex);
46
+ const schemaName = `${alias}Schema`;
47
+ return `const ${schemaName} = z.object(${alias}.definition.args);`;
48
+ })
49
+ .join("\n");
50
+
51
+ const schedulerSchemas = schedulerOperations
52
+ .map((operation) => {
53
+ const operationIndex = operations.indexOf(operation);
54
+ const alias = buildImportAlias(operation, operationIndex);
55
+ const schemaName = `${alias}SchedulerSchema`;
56
+ return `const ${schemaName} = ${alias}.definition.args ? z.object(${alias}.definition.args) : z.undefined();`;
57
+ })
58
+ .join("\n");
59
+
60
+ const queryRoutes = httpOperations
61
+ .filter((operation) => operation.kind === "query")
62
+ .map((operation) => {
63
+ const operationIndex = operations.indexOf(operation);
64
+ const alias = buildImportAlias(operation, operationIndex);
65
+ const schemaName = `${alias}Schema`;
66
+ return `
67
+ app.get(
68
+ "${operation.routePath}",
69
+ sValidator("query", ${schemaName}),
70
+ async (c) => {
71
+ const ctx = await createExecutionContext(c, options);
72
+ try {
73
+ return await executeOperation(c, ${alias}, c.req.valid("query"), ctx);
74
+ } catch (error) {
75
+ return handleOperationError(c, error, "Invalid query arguments");
76
+ }
77
+ },
78
+ );`;
79
+ })
80
+ .join("\n");
81
+
82
+ const mutationRoutes = httpOperations
83
+ .filter((operation) => operation.kind === "mutation")
84
+ .map((operation) => {
85
+ const operationIndex = operations.indexOf(operation);
86
+ const alias = buildImportAlias(operation, operationIndex);
87
+ const schemaName = `${alias}Schema`;
88
+ return `
89
+ app.post(
90
+ "${operation.routePath}",
91
+ sValidator("json", ${schemaName}),
92
+ async (c) => {
93
+ const ctx = await createExecutionContext(c, options);
94
+ try {
95
+ const response = await executeOperation(c, ${alias}, c.req.valid("json"), ctx);
96
+ await publishMutationEvents(c, options, ctx.mutationEvents);
97
+ return response;
98
+ } catch (error) {
99
+ return handleOperationError(c, error, "Invalid mutation arguments");
100
+ }
101
+ },
102
+ );`;
103
+ })
104
+ .join("\n");
105
+
106
+ const queryRegistryEntries = httpOperations
107
+ .filter((operation) => operation.kind === "query")
108
+ .map((operation) => {
109
+ const operationIndex = operations.indexOf(operation);
110
+ const alias = buildImportAlias(operation, operationIndex);
111
+ const schemaName = `${alias}Schema`;
112
+ const queryName = operation.handlerName ?? operation.routePath;
113
+ return `
114
+ ${JSON.stringify(queryName)}: {
115
+ definition: ${alias}.definition,
116
+ schema: ${schemaName},
117
+ },`;
118
+ })
119
+ .join("\n");
120
+
121
+ const schedulerEntries = schedulerOperations
122
+ .map((operation) => {
123
+ const operationIndex = operations.indexOf(operation);
124
+ const alias = buildImportAlias(operation, operationIndex);
125
+ const schemaName = `${alias}SchedulerSchema`;
126
+ const taskName = operation.taskName ?? `${operation.routePath}`;
127
+ return `
128
+ ${JSON.stringify(taskName)}: {
129
+ definition: ${alias}.definition,
130
+ schema: ${schemaName},
131
+ },`;
132
+ })
133
+ .join("\n");
134
+
135
+ const schedulerPayloadMapEntries = schedulerOperations
136
+ .map((operation) => {
137
+ const operationIndex = operations.indexOf(operation);
138
+ const alias = buildImportAlias(operation, operationIndex);
139
+ const taskName = operation.taskName ?? `${operation.routePath}`;
140
+ return `\t${JSON.stringify(taskName)}: Parameters<typeof ${alias}.definition.handler>[1];`;
141
+ })
142
+ .join("\n");
143
+
144
+ const cronEntries = cronOperations
145
+ .map((operation) => {
146
+ const operationIndex = operations.indexOf(operation);
147
+ const alias = buildImportAlias(operation, operationIndex);
148
+ const taskName = operation.taskName ?? `${operation.routePath}`;
149
+ const cronTriggers = operation.cronTriggers ?? [];
150
+ return `
151
+ {
152
+ taskName: ${JSON.stringify(taskName)},
153
+ cronTriggers: ${JSON.stringify(cronTriggers)},
154
+ definition: ${alias}.definition,
155
+ },`;
156
+ })
157
+ .join("\n");
158
+
159
+ const storageHandlersEntries = storageOperations
160
+ .map((operation) => {
161
+ const operationIndex = operations.indexOf(operation);
162
+ const alias = buildImportAlias(operation, operationIndex);
163
+ return `\n\t${alias}.definition.handler,`;
164
+ })
165
+ .join("\n");
166
+
167
+ return `import { sValidator } from "@hono/standard-validator";
168
+ import type { Hono } from "hono";
169
+ import type { D1Database, IncomingRequestCfProperties, KVNamespace } from "@cloudflare/workers-types";
170
+ import { ZodError, z } from "zod";
171
+ import {
172
+ AppflareHandledError,
173
+ type AppflareContext,
174
+ type DbMutationEvent,
175
+ createDb,
176
+ createQueryDb,
177
+ setStorageHandlers,
178
+ type RegisterHandlersOptions,
179
+ type StorageMethod,
180
+ type WorkerEnv,
181
+ } from "./handlers";
182
+ import { createExecutionContext, createSchedulerExecutionContext, resolveSession } from "./handlers.context";
183
+ import { executeOperation, handleOperationError } from "./handlers.execution";
184
+ ${imports ? `\n${imports}` : ""}
185
+
186
+ ${operationSchemas}
187
+ ${schedulerSchemas}
188
+
189
+ const realtimeQueryHandlers = {${queryRegistryEntries || "\n"}
190
+ } as const;
191
+
192
+ const schedulerHandlers = {${schedulerEntries || "\n"}
193
+ } as const;
194
+
195
+ type GeneratedSchedulerPayloadMap = {${
196
+ schedulerPayloadMapEntries ? `\n${schedulerPayloadMapEntries}\n` : ""
197
+ }};
198
+
199
+ declare global {
200
+ interface AppflareSchedulerHandlerMap extends GeneratedSchedulerPayloadMap {}
201
+ }
202
+
203
+ const cronHandlers = [${cronEntries || "\n"}
204
+ ] as const;
205
+
206
+ const storageHandlers = [${storageHandlersEntries || "\n"}
207
+ ] as const;
208
+
209
+ setStorageHandlers([...storageHandlers]);
210
+
211
+ type SchedulerTaskName = keyof typeof schedulerHandlers extends never
212
+ ? string
213
+ : keyof typeof schedulerHandlers;
214
+
215
+ type QueueMessageBody = {
216
+ task?: string;
217
+ payload?: unknown;
218
+ };
219
+
220
+ type RealtimeSubscription = {
221
+ token: string;
222
+ signature: string;
223
+ queryName: string;
224
+ args: Record<string, unknown>;
225
+ authToken: string;
226
+ userId: string;
227
+ createdAt: number;
228
+ };
229
+
230
+ type RealtimeEmitPayload = {
231
+ token: string;
232
+ event: string;
233
+ payload: unknown;
234
+ };
235
+
236
+ type RealtimeStub = {
237
+ fetch: (request: Request) => Promise<Response>;
238
+ };
239
+
240
+ type RealtimeDurableObjectNamespace = {
241
+ idFromName: (name: string) => unknown;
242
+ get: (id: unknown) => RealtimeStub;
243
+ };
244
+
245
+ type RealtimeQueryName = keyof typeof realtimeQueryHandlers extends never
246
+ ? string
247
+ : Extract<keyof typeof realtimeQueryHandlers, string>;
248
+
249
+ function isRecord(value: unknown): value is Record<string, unknown> {
250
+ return typeof value === "object" && value !== null;
251
+ }
252
+
253
+ function stableStringify(value: unknown): string {
254
+ if (Array.isArray(value)) {
255
+ return "[" + value.map((entry) => stableStringify(entry)).join(",") + "]";
256
+ }
257
+
258
+ if (value instanceof Date) {
259
+ return JSON.stringify(value.toISOString());
260
+ }
261
+
262
+ if (isRecord(value)) {
263
+ const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
264
+ return (
265
+ "{" +
266
+ keys
267
+ .map((key) => JSON.stringify(key) + ":" + stableStringify(value[key]))
268
+ .join(",") +
269
+ "}"
270
+ );
271
+ }
272
+
273
+ return JSON.stringify(value ?? null);
274
+ }
275
+
276
+ function normalizeComparableValue(value: unknown): unknown {
277
+ if (Array.isArray(value)) {
278
+ return value.map((entry) => normalizeComparableValue(entry));
279
+ }
280
+
281
+ if (value instanceof Date) {
282
+ return value.toISOString();
283
+ }
284
+
285
+ if (isRecord(value)) {
286
+ return Object.entries(value)
287
+ .sort(([a], [b]) => a.localeCompare(b))
288
+ .reduce<Record<string, unknown>>((accumulator, [key, entry]) => {
289
+ accumulator[key] = normalizeComparableValue(entry);
290
+ return accumulator;
291
+ }, {});
292
+ }
293
+
294
+ return value;
295
+ }
296
+
297
+ function createSubscriptionSignature(
298
+ queryName: string,
299
+ args: Record<string, unknown>,
300
+ ): string {
301
+ const normalizedArgs = normalizeComparableValue(args);
302
+ return queryName + "::" + stableStringify(normalizedArgs);
303
+ }
304
+
305
+ function normalizeSubscriptionFilter(args: Record<string, unknown>): unknown {
306
+ const where = args.where;
307
+ if (isRecord(where)) {
308
+ return where;
309
+ }
310
+
311
+ return args;
312
+ }
313
+
314
+ function hasPartialOverlap(left: unknown, right: unknown): boolean {
315
+ if (left === null || left === undefined || right === null || right === undefined) {
316
+ return false;
317
+ }
318
+
319
+ if (Array.isArray(left) && Array.isArray(right)) {
320
+ return left.some((leftValue) => {
321
+ return right.some((rightValue) => hasPartialOverlap(leftValue, rightValue));
322
+ });
323
+ }
324
+
325
+ if (Array.isArray(left)) {
326
+ return left.some((leftValue) => hasPartialOverlap(leftValue, right));
327
+ }
328
+
329
+ if (Array.isArray(right)) {
330
+ return right.some((rightValue) => hasPartialOverlap(left, rightValue));
331
+ }
332
+
333
+ if (isRecord(left) && isRecord(right)) {
334
+ const keys = Object.keys(left);
335
+ for (const key of keys) {
336
+ if (!(key in right)) {
337
+ continue;
338
+ }
339
+
340
+ if (hasPartialOverlap(left[key], right[key])) {
341
+ return true;
342
+ }
343
+ }
344
+
345
+ return false;
346
+ }
347
+
348
+ return left === right;
349
+ }
350
+
351
+ function doesSubscriptionMatchMutation(
352
+ subscriptionArgs: Record<string, unknown>,
353
+ event: DbMutationEvent,
354
+ ): boolean {
355
+ const filter = normalizeSubscriptionFilter(subscriptionArgs);
356
+ if (!filter) {
357
+ return true;
358
+ }
359
+
360
+ if (hasPartialOverlap(filter, event.args)) {
361
+ return true;
362
+ }
363
+
364
+ return event.rows.some((row) => hasPartialOverlap(filter, row));
365
+ }
366
+
367
+ function getRealtimeStub(
368
+ env: Record<string, unknown>,
369
+ options: RegisterHandlersOptions,
370
+ ): RealtimeStub | null {
371
+ const binding = options.realtimeBinding ?? "APPFLARE_REALTIME";
372
+ const namespace = env[binding] as RealtimeDurableObjectNamespace | undefined;
373
+ if (!namespace) {
374
+ return null;
375
+ }
376
+
377
+ const objectName = options.realtimeObjectName ?? "global";
378
+ const objectId = namespace.idFromName(objectName);
379
+ return namespace.get(objectId);
380
+ }
381
+
382
+ async function validateAuthToken(
383
+ request: Request,
384
+ env: Record<string, unknown>,
385
+ options: RegisterHandlersOptions,
386
+ authToken: string,
387
+ ): Promise<{ user: unknown; session: unknown } | null> {
388
+ const database = env[options.databaseBinding] as D1Database | undefined;
389
+ if (!database) {
390
+ return null;
391
+ }
392
+
393
+ const kvNamespace = options.kvBinding
394
+ ? (env[options.kvBinding] as KVNamespace | undefined)
395
+ : undefined;
396
+ const headers = new Headers(request.headers);
397
+ headers.set("authorization", "Bearer " + authToken);
398
+ const tokenRequest = new Request(request.url, {
399
+ method: request.method,
400
+ headers,
401
+ });
402
+
403
+ const session = await resolveSession(
404
+ tokenRequest,
405
+ database,
406
+ kvNamespace,
407
+ request.cf as IncomingRequestCfProperties | undefined,
408
+ );
409
+
410
+ if (!session?.user) {
411
+ return null;
412
+ }
413
+
414
+ return session;
415
+ }
416
+
417
+ function extractUserId(user: unknown): string {
418
+ if (!isRecord(user)) {
419
+ return "unknown";
420
+ }
421
+
422
+ const id = user.id;
423
+ return typeof id === "string" && id.length > 0 ? id : "unknown";
424
+ }
425
+
426
+ function buildRealtimeWsUrl(requestUrl: string, websocketPath: string): string {
427
+ const url = new URL(requestUrl);
428
+ url.pathname = websocketPath;
429
+ url.search = "";
430
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
431
+ return url.toString();
432
+ }
433
+
434
+ async function publishMutationEvents(
435
+ c: { req: { raw: Request }; env: Record<string, unknown> },
436
+ options: RegisterHandlersOptions,
437
+ mutationEvents: DbMutationEvent[],
438
+ ): Promise<void> {
439
+ if (mutationEvents.length === 0) {
440
+ return;
441
+ }
442
+
443
+ const stub = getRealtimeStub(c.env, options);
444
+ if (!stub) {
445
+ return;
446
+ }
447
+
448
+ const subscriptionsResponse = await stub.fetch(
449
+ new Request("https://realtime.internal/subscriptions", {
450
+ method: "POST",
451
+ headers: {
452
+ "content-type": "application/json",
453
+ },
454
+ }),
455
+ );
456
+ if (!subscriptionsResponse.ok) {
457
+ return;
458
+ }
459
+
460
+ const payload = (await subscriptionsResponse.json()) as {
461
+ subscriptions?: RealtimeSubscription[];
462
+ };
463
+ const subscriptions = Array.isArray(payload.subscriptions)
464
+ ? payload.subscriptions
465
+ : [];
466
+
467
+ for (const subscription of subscriptions) {
468
+ const operation = (realtimeQueryHandlers as Record<
469
+ string,
470
+ {
471
+ definition: {
472
+ handler: (ctx: AppflareContext, args: unknown) => Promise<unknown> | unknown;
473
+ };
474
+ schema: z.ZodTypeAny;
475
+ }
476
+ >)[subscription.queryName];
477
+ if (!operation) {
478
+ continue;
479
+ }
480
+
481
+ const shouldPublish = mutationEvents.some((event) => {
482
+ return doesSubscriptionMatchMutation(subscription.args, event);
483
+ });
484
+ if (!shouldPublish) {
485
+ continue;
486
+ }
487
+
488
+ const authSession = await validateAuthToken(
489
+ c.req.raw,
490
+ c.env,
491
+ options,
492
+ subscription.authToken,
493
+ );
494
+ if (!authSession) {
495
+ continue;
496
+ }
497
+
498
+ const subscriberCtx = await createExecutionContext(c as never, options);
499
+ subscriberCtx.user = authSession.user as never;
500
+ subscriberCtx.session = authSession.session as never;
501
+
502
+ try {
503
+ const parsedArgs = operation.schema.parse(subscription.args);
504
+ const result = await operation.definition.handler(subscriberCtx, parsedArgs);
505
+ const emitPayload: RealtimeEmitPayload = {
506
+ token: subscription.token,
507
+ event: "query:update",
508
+ payload: {
509
+ queryName: subscription.queryName,
510
+ signature: subscription.signature,
511
+ data: result,
512
+ },
513
+ };
514
+
515
+ await stub.fetch(
516
+ new Request("https://realtime.internal/emit", {
517
+ method: "POST",
518
+ headers: {
519
+ "content-type": "application/json",
520
+ },
521
+ body: JSON.stringify(emitPayload),
522
+ }),
523
+ );
524
+ } catch (error) {
525
+ console.warn("Failed to publish realtime update", subscription.queryName, error);
526
+ }
527
+ }
528
+ }
529
+
530
+ function registerRealtimeRoutes(
531
+ app: Hono<WorkerEnv>,
532
+ options: RegisterHandlersOptions,
533
+ ): void {
534
+ const subscribePath = options.realtimeSubscribePath ?? "/realtime/subscribe";
535
+ const unsubscribePath = "/realtime/unsubscribe";
536
+ const websocketPath = options.realtimeWebsocketPath ?? "/realtime/ws";
537
+ const protocol = options.realtimeProtocol ?? "appflare.realtime.v1";
538
+
539
+ app.post(subscribePath, async (c) => {
540
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
541
+ const queryName = typeof body.queryName === "string" ? body.queryName : "";
542
+ const authToken = typeof body.authToken === "string" ? body.authToken : "";
543
+ const rawArgs = isRecord(body.args) ? body.args : {};
544
+
545
+ if (!queryName || !authToken) {
546
+ return c.json({ message: "queryName and authToken are required" }, 400);
547
+ }
548
+
549
+ const operation = (realtimeQueryHandlers as Record<
550
+ string,
551
+ {
552
+ definition: unknown;
553
+ schema: z.ZodTypeAny;
554
+ }
555
+ >)[queryName as RealtimeQueryName];
556
+ if (!operation) {
557
+ return c.json({ message: "Unknown queryName" }, 404);
558
+ }
559
+
560
+ const authSession = await validateAuthToken(
561
+ c.req.raw,
562
+ c.env,
563
+ options,
564
+ authToken,
565
+ );
566
+ if (!authSession) {
567
+ return c.json({ message: "Invalid auth token" }, 401);
568
+ }
569
+
570
+ let args: Record<string, unknown>;
571
+ try {
572
+ args = operation.schema.parse(rawArgs) as Record<string, unknown>;
573
+ } catch (error) {
574
+ if (error instanceof ZodError) {
575
+ return c.json({ message: "Invalid query args", issues: error.issues }, 400);
576
+ }
577
+ return c.json({ message: "Invalid query args" }, 400);
578
+ }
579
+
580
+ const token = crypto.randomUUID();
581
+ const signature = createSubscriptionSignature(queryName, args);
582
+ const stub = getRealtimeStub(c.env, options);
583
+ if (!stub) {
584
+ return c.json({ message: "Realtime binding is not configured" }, 500);
585
+ }
586
+
587
+ await stub.fetch(
588
+ new Request("https://realtime.internal/subscribe", {
589
+ method: "POST",
590
+ headers: {
591
+ "content-type": "application/json",
592
+ },
593
+ body: JSON.stringify({
594
+ token,
595
+ signature,
596
+ queryName,
597
+ args,
598
+ authToken,
599
+ userId: extractUserId(authSession.user),
600
+ createdAt: Date.now(),
601
+ }),
602
+ }),
603
+ );
604
+
605
+ const websocketUrl = buildRealtimeWsUrl(c.req.raw.url, websocketPath);
606
+ return c.json(
607
+ {
608
+ token,
609
+ signature,
610
+ websocket: {
611
+ url: websocketUrl,
612
+ protocol,
613
+ params: {
614
+ tokenParam: "token",
615
+ authTokenParam: "authToken",
616
+ },
617
+ },
618
+ },
619
+ 200,
620
+ );
621
+ });
622
+
623
+ app.post(unsubscribePath, async (c) => {
624
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
625
+ const token = typeof body.token === "string" ? body.token : "";
626
+ const authToken = typeof body.authToken === "string" ? body.authToken : "";
627
+
628
+ if (!token || !authToken) {
629
+ return c.json({ message: "token and authToken are required" }, 400);
630
+ }
631
+
632
+ const authSession = await validateAuthToken(
633
+ c.req.raw,
634
+ c.env,
635
+ options,
636
+ authToken,
637
+ );
638
+ if (!authSession) {
639
+ return c.json({ message: "Invalid auth token" }, 401);
640
+ }
641
+
642
+ const stub = getRealtimeStub(c.env, options);
643
+ if (!stub) {
644
+ return c.json({ message: "Realtime binding is not configured" }, 500);
645
+ }
646
+
647
+ const response = await stub.fetch(
648
+ new Request("https://realtime.internal/unsubscribe", {
649
+ method: "POST",
650
+ headers: {
651
+ "content-type": "application/json",
652
+ },
653
+ body: JSON.stringify({
654
+ token,
655
+ authToken,
656
+ }),
657
+ }),
658
+ );
659
+
660
+ if (!response.ok) {
661
+ const payload = (await response.json().catch(() => null)) as {
662
+ message?: string;
663
+ } | null;
664
+ return c.json(
665
+ { message: payload?.message ?? "Unable to remove subscription" },
666
+ response.status,
667
+ );
668
+ }
669
+
670
+ return c.json({ ok: true }, 200);
671
+ });
672
+
673
+ app.get(websocketPath, async (c) => {
674
+ const token = c.req.query("token") ?? "";
675
+ const authToken = c.req.query("authToken") ?? "";
676
+
677
+ if (!token || !authToken) {
678
+ return c.json({ message: "token and authToken are required" }, 400);
679
+ }
680
+
681
+ const authSession = await validateAuthToken(
682
+ c.req.raw,
683
+ c.env,
684
+ options,
685
+ authToken,
686
+ );
687
+ if (!authSession) {
688
+ return c.json({ message: "Invalid auth token" }, 401);
689
+ }
690
+
691
+ const stub = getRealtimeStub(c.env, options);
692
+ if (!stub) {
693
+ return c.json({ message: "Realtime binding is not configured" }, 500);
694
+ }
695
+
696
+ const wsUrl = new URL("https://realtime.internal/ws");
697
+ wsUrl.searchParams.set("token", token);
698
+ wsUrl.searchParams.set("authToken", authToken);
699
+ return stub.fetch(new Request(wsUrl.toString(), c.req.raw));
700
+ });
701
+ }
702
+
703
+ export class AppflareRealtimeDurableObject {
704
+ private readonly subscriptions = new Map<string, RealtimeSubscription>();
705
+ private readonly sockets = new Map<string, WebSocket>();
706
+
707
+ public constructor(_state: unknown) {}
708
+
709
+ public async fetch(request: Request): Promise<Response> {
710
+ const url = new URL(request.url);
711
+
712
+ if (request.method === "POST" && url.pathname === "/subscribe") {
713
+ const payload = (await request.json().catch(() => null)) as RealtimeSubscription | null;
714
+ if (!payload?.token || !payload.queryName || !payload.authToken) {
715
+ return new Response(JSON.stringify({ message: "Invalid subscription payload" }), {
716
+ status: 400,
717
+ headers: { "content-type": "application/json" },
718
+ });
719
+ }
720
+
721
+ this.subscriptions.set(payload.token, payload);
722
+ return new Response(JSON.stringify({ ok: true }), {
723
+ status: 200,
724
+ headers: { "content-type": "application/json" },
725
+ });
726
+ }
727
+
728
+ if (request.method === "POST" && url.pathname === "/subscriptions") {
729
+ return new Response(
730
+ JSON.stringify({ subscriptions: Array.from(this.subscriptions.values()) }),
731
+ {
732
+ status: 200,
733
+ headers: { "content-type": "application/json" },
734
+ },
735
+ );
736
+ }
737
+
738
+ if (request.method === "POST" && url.pathname === "/unsubscribe") {
739
+ const payload = (await request.json().catch(() => null)) as {
740
+ token?: unknown;
741
+ authToken?: unknown;
742
+ } | null;
743
+ const token = typeof payload?.token === "string" ? payload.token : "";
744
+ const authToken =
745
+ typeof payload?.authToken === "string" ? payload.authToken : "";
746
+
747
+ if (!token || !authToken) {
748
+ return new Response(
749
+ JSON.stringify({ message: "token and authToken are required" }),
750
+ {
751
+ status: 400,
752
+ headers: { "content-type": "application/json" },
753
+ },
754
+ );
755
+ }
756
+
757
+ const existing = this.subscriptions.get(token);
758
+ if (!existing || existing.authToken !== authToken) {
759
+ return new Response(JSON.stringify({ message: "Subscription not found" }), {
760
+ status: 404,
761
+ headers: { "content-type": "application/json" },
762
+ });
763
+ }
764
+
765
+ const socket = this.sockets.get(token);
766
+ if (socket && socket.readyState === 1) {
767
+ socket.close();
768
+ }
769
+
770
+ this.sockets.delete(token);
771
+ this.subscriptions.delete(token);
772
+
773
+ return new Response(JSON.stringify({ ok: true }), {
774
+ status: 200,
775
+ headers: { "content-type": "application/json" },
776
+ });
777
+ }
778
+
779
+ if (request.method === "POST" && url.pathname === "/emit") {
780
+ const payload = (await request.json().catch(() => null)) as RealtimeEmitPayload | null;
781
+ if (!payload?.token || !payload.event) {
782
+ return new Response(JSON.stringify({ message: "Invalid emit payload" }), {
783
+ status: 400,
784
+ headers: { "content-type": "application/json" },
785
+ });
786
+ }
787
+
788
+ const socket = this.sockets.get(payload.token);
789
+ if (socket && socket.readyState === 1) {
790
+ socket.send(
791
+ JSON.stringify({
792
+ event: payload.event,
793
+ payload: payload.payload,
794
+ }),
795
+ );
796
+ }
797
+
798
+ return new Response(JSON.stringify({ ok: true }), {
799
+ status: 200,
800
+ headers: { "content-type": "application/json" },
801
+ });
802
+ }
803
+
804
+ if (request.method === "GET" && url.pathname === "/ws") {
805
+ const token = url.searchParams.get("token") ?? "";
806
+ const authToken = url.searchParams.get("authToken") ?? "";
807
+ const subscription = this.subscriptions.get(token);
808
+ if (!subscription || !authToken || subscription.authToken !== authToken) {
809
+ return new Response("Unauthorized", { status: 401 });
810
+ }
811
+
812
+ const pair = new WebSocketPair();
813
+ const [clientSocket, serverSocket] = Object.values(pair);
814
+ serverSocket.accept();
815
+ this.sockets.set(token, serverSocket);
816
+
817
+ const release = () => {
818
+ this.sockets.delete(token);
819
+ };
820
+
821
+ serverSocket.addEventListener("close", release);
822
+ serverSocket.addEventListener("error", release);
823
+ serverSocket.addEventListener("message", (event) => {
824
+ if (String(event.data ?? "").trim() === "ping") {
825
+ serverSocket.send(JSON.stringify({ event: "pong" }));
826
+ }
827
+ });
828
+
829
+ return new Response(null, {
830
+ status: 101,
831
+ webSocket: clientSocket,
832
+ } as ResponseInit & { webSocket: WebSocket });
833
+ }
834
+
835
+ return new Response("Not found", { status: 404 });
836
+ }
837
+ }
838
+
839
+ export function registerGeneratedHandlers(
840
+ app: Hono<WorkerEnv>,
841
+ options: RegisterHandlersOptions,
842
+ ): void {
843
+ registerRealtimeRoutes(app, options);${queryRoutes || "\n\t// No query handlers discovered under scanDir/queries.\n"}${mutationRoutes || "\n\t// No mutation handlers discovered under scanDir/mutations.\n"}
844
+ }
845
+
846
+ function parseExpiresIn(value: string | undefined): number | undefined {
847
+ if (!value) {
848
+ return undefined;
849
+ }
850
+
851
+ const parsed = Number(value);
852
+ if (!Number.isFinite(parsed) || parsed <= 0) {
853
+ return undefined;
854
+ }
855
+
856
+ return Math.floor(parsed);
857
+ }
858
+
859
+ function toStoragePath(path: string): string {
860
+ const trimmed = path.trim();
861
+ if (!trimmed) {
862
+ throw new Error("Storage path is required");
863
+ }
864
+
865
+ return trimmed.startsWith("/") ? trimmed : "/" + trimmed;
866
+ }
867
+
868
+ function readStoragePath(c: { req: { query: (name: string) => string | undefined } }): string {
869
+ const path = c.req.query("path") ?? "";
870
+ return toStoragePath(path);
871
+ }
872
+
873
+ export function registerGeneratedStorageRoutes(
874
+ app: Hono<WorkerEnv>,
875
+ options: RegisterHandlersOptions,
876
+ ): void {
877
+ app.post("/storage/upload", async (c) => {
878
+ const ctx = await createExecutionContext(c, options);
879
+ try {
880
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
881
+ const path = toStoragePath(String(body.path ?? ""));
882
+ const contentType =
883
+ typeof body.contentType === "string" ? body.contentType : undefined;
884
+ const expiresIn =
885
+ typeof body.expiresIn === "number" && body.expiresIn > 0
886
+ ? Math.floor(body.expiresIn)
887
+ : undefined;
888
+
889
+ const url = await ctx.storage.signedUrl({
890
+ path,
891
+ method: "PUT",
892
+ expiresIn,
893
+ contentType,
894
+ });
895
+
896
+ return c.json({
897
+ url,
898
+ method: "PUT",
899
+ path,
900
+ expiresIn: expiresIn ?? 300,
901
+ }, 200);
902
+ } catch (error) {
903
+ if (error instanceof AppflareHandledError) {
904
+ return c.json(error.payload, error.status);
905
+ }
906
+
907
+ return c.json(
908
+ { message: (error as Error).message ?? "Unable to create upload URL" },
909
+ 400,
910
+ );
911
+ }
912
+ });
913
+
914
+ app.get("/storage/download", async (c) => {
915
+ const ctx = await createExecutionContext(c, options);
916
+ try {
917
+ const path = readStoragePath(c);
918
+ const fileName = c.req.query("fileName") ?? undefined;
919
+ const expiresIn = parseExpiresIn(c.req.query("expiresIn"));
920
+ const url = await ctx.storage.signedUrl({
921
+ path,
922
+ method: "GET",
923
+ expiresIn,
924
+ downloadAsAttachment: true,
925
+ fileName,
926
+ });
927
+
928
+ return c.json({
929
+ url,
930
+ method: "GET",
931
+ path,
932
+ disposition: "attachment",
933
+ }, 200);
934
+ } catch (error) {
935
+ if (error instanceof AppflareHandledError) {
936
+ return c.json(error.payload, error.status);
937
+ }
938
+
939
+ return c.json(
940
+ { message: (error as Error).message ?? "Unable to create download URL" },
941
+ 400,
942
+ );
943
+ }
944
+ });
945
+
946
+ app.get("/storage/preview", async (c) => {
947
+ const ctx = await createExecutionContext(c, options);
948
+ try {
949
+ const path = readStoragePath(c);
950
+ const expiresIn = parseExpiresIn(c.req.query("expiresIn"));
951
+ const url = await ctx.storage.signedUrl({
952
+ path,
953
+ method: "GET",
954
+ expiresIn,
955
+ downloadAsAttachment: false,
956
+ });
957
+
958
+ return c.json({
959
+ url,
960
+ method: "GET",
961
+ path,
962
+ disposition: "inline",
963
+ }, 200);
964
+ } catch (error) {
965
+ if (error instanceof AppflareHandledError) {
966
+ return c.json(error.payload, error.status);
967
+ }
968
+
969
+ return c.json(
970
+ { message: (error as Error).message ?? "Unable to create preview URL" },
971
+ 400,
972
+ );
973
+ }
974
+ });
975
+
976
+ app.delete("/storage/object", async (c) => {
977
+ const ctx = await createExecutionContext(c, options);
978
+ try {
979
+ const path = readStoragePath(c);
980
+ await ctx.storage.delete({ path });
981
+ return c.json({ ok: true, path }, 200);
982
+ } catch (error) {
983
+ if (error instanceof AppflareHandledError) {
984
+ return c.json(error.payload, error.status);
985
+ }
986
+
987
+ return c.json(
988
+ { message: (error as Error).message ?? "Unable to delete object" },
989
+ 400,
990
+ );
991
+ }
992
+ });
993
+
994
+ app.get("/storage/list", async (c) => {
995
+ const ctx = await createExecutionContext(c, options);
996
+ try {
997
+ const prefix = c.req.query("prefix") ?? undefined;
998
+ const cursor = c.req.query("cursor") ?? undefined;
999
+ const delimiter = c.req.query("delimiter") ?? undefined;
1000
+ const limitValue = c.req.query("limit");
1001
+ const parsedLimit = limitValue ? Number(limitValue) : undefined;
1002
+ const limit =
1003
+ typeof parsedLimit === "number" && Number.isFinite(parsedLimit) && parsedLimit > 0
1004
+ ? Math.floor(parsedLimit)
1005
+ : undefined;
1006
+ const methodValue = c.req.query("method");
1007
+ const method: StorageMethod | undefined =
1008
+ methodValue === "download" ||
1009
+ methodValue === "get" ||
1010
+ methodValue === "delete" ||
1011
+ methodValue === "list" ||
1012
+ methodValue === "put" ||
1013
+ methodValue === "preview"
1014
+ ? methodValue
1015
+ : undefined;
1016
+
1017
+ const result = await ctx.storage.list({
1018
+ prefix,
1019
+ cursor,
1020
+ delimiter,
1021
+ limit,
1022
+ method,
1023
+ });
1024
+
1025
+ return c.json(result, 200);
1026
+ } catch (error) {
1027
+ if (error instanceof AppflareHandledError) {
1028
+ return c.json(error.payload, error.status);
1029
+ }
1030
+
1031
+ return c.json(
1032
+ { message: (error as Error).message ?? "Unable to list storage objects" },
1033
+ 400,
1034
+ );
1035
+ }
1036
+ });
1037
+ }
1038
+
1039
+ export async function executeScheduledBatch(
1040
+ batch: { messages?: Array<{ body?: unknown }> },
1041
+ env: Record<string, unknown>,
1042
+ options: RegisterHandlersOptions,
1043
+ ): Promise<void> {
1044
+ if (!batch?.messages || batch.messages.length === 0) {
1045
+ return;
1046
+ }
1047
+
1048
+ const ctx = createSchedulerExecutionContext(env, options);
1049
+
1050
+ for (const message of batch.messages) {
1051
+ const body = (message?.body ?? {}) as QueueMessageBody;
1052
+ const task = body.task;
1053
+ if (!task) {
1054
+ console.warn("Scheduler message missing task field");
1055
+ continue;
1056
+ }
1057
+
1058
+ const operation = (schedulerHandlers as Record<string, {
1059
+ definition: {
1060
+ handler: (ctx: typeof ctx, args: unknown) => Promise<void> | void;
1061
+ };
1062
+ schema: z.ZodTypeAny;
1063
+ }>)[task];
1064
+
1065
+ if (!operation) {
1066
+ console.warn("Unknown scheduler task", task);
1067
+ continue;
1068
+ }
1069
+
1070
+ try {
1071
+ const payloadValue = body.payload === null ? undefined : body.payload;
1072
+ const parsed = operation.schema.parse(payloadValue);
1073
+ await operation.definition.handler(ctx, parsed);
1074
+ } catch (error) {
1075
+ if (error instanceof ZodError) {
1076
+ console.error("Invalid scheduler payload", task, error.issues);
1077
+ continue;
1078
+ }
1079
+
1080
+ console.error("Scheduler task failed", task, error);
1081
+ }
1082
+ }
1083
+ }
1084
+
1085
+ export async function executeCronTriggers(
1086
+ controller: { cron: string },
1087
+ env: Record<string, unknown>,
1088
+ options: RegisterHandlersOptions,
1089
+ ): Promise<void> {
1090
+ const cronValue = controller?.cron;
1091
+ if (!cronValue) {
1092
+ return;
1093
+ }
1094
+
1095
+ if (cronHandlers.length === 0) {
1096
+ return;
1097
+ }
1098
+
1099
+ const ctx = createSchedulerExecutionContext(env, options);
1100
+
1101
+ for (const cronEntry of cronHandlers) {
1102
+ if (!cronEntry.cronTriggers.includes(cronValue)) {
1103
+ continue;
1104
+ }
1105
+
1106
+ try {
1107
+ await cronEntry.definition.handler(ctx);
1108
+ } catch (error) {
1109
+ console.error("Cron task failed", cronEntry.taskName, error);
1110
+ }
1111
+ }
1112
+ }
1113
+ `;
1114
+ }