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.
- package/cli/commands/index.ts +140 -0
- package/cli/generate.ts +149 -0
- package/cli/index.ts +56 -447
- package/cli/load-config.ts +182 -0
- package/cli/schema-compiler.ts +657 -0
- package/cli/templates/auth/README.md +156 -0
- package/cli/templates/auth/config.ts +61 -0
- package/cli/templates/auth/route-config.ts +18 -0
- package/cli/templates/auth/route-handler.ts +18 -0
- package/cli/templates/auth/route-request-utils.ts +55 -0
- package/cli/templates/auth/route.ts +14 -0
- package/cli/templates/core/README.md +266 -0
- package/cli/templates/core/app-creation.ts +19 -0
- package/cli/templates/core/client/appflare.ts +37 -0
- package/cli/templates/core/client/index.ts +6 -0
- package/cli/templates/core/client/storage.ts +100 -0
- package/cli/templates/core/client/types.ts +54 -0
- package/cli/templates/core/client-modules/appflare.ts +112 -0
- package/cli/templates/core/client-modules/handlers/index.ts +740 -0
- package/cli/templates/core/client-modules/handlers.ts +1 -0
- package/cli/templates/core/client-modules/index.ts +7 -0
- package/cli/templates/core/client-modules/storage.ts +180 -0
- package/cli/templates/core/client-modules/types.ts +145 -0
- package/cli/templates/core/client.ts +39 -0
- package/cli/templates/core/drizzle.ts +15 -0
- package/cli/templates/core/export.ts +14 -0
- package/cli/templates/core/handlers-route.ts +23 -0
- package/cli/templates/core/handlers.ts +1 -0
- package/cli/templates/core/imports.ts +8 -0
- package/cli/templates/core/server.ts +38 -0
- package/cli/templates/core/types.ts +6 -0
- package/cli/templates/core/wrangler.ts +109 -0
- package/cli/templates/handlers/README.md +265 -0
- package/cli/templates/handlers/auth.ts +36 -0
- package/cli/templates/handlers/execution.ts +39 -0
- package/cli/templates/handlers/generators/context/context-creation.ts +80 -0
- package/cli/templates/handlers/generators/context/error-helpers.ts +11 -0
- package/cli/templates/handlers/generators/context/scheduler.ts +24 -0
- package/cli/templates/handlers/generators/context/storage-api.ts +112 -0
- package/cli/templates/handlers/generators/context/storage-helpers.ts +59 -0
- package/cli/templates/handlers/generators/context/types.ts +18 -0
- package/cli/templates/handlers/generators/context.ts +43 -0
- package/cli/templates/handlers/generators/execution.ts +15 -0
- package/cli/templates/handlers/generators/handlers.ts +13 -0
- package/cli/templates/handlers/index.ts +43 -0
- package/cli/templates/handlers/operations.ts +116 -0
- package/cli/templates/handlers/registration.ts +1114 -0
- package/cli/templates/handlers/types.ts +960 -0
- package/cli/templates/handlers/utils.ts +48 -0
- package/cli/types.ts +108 -0
- package/cli/utils/handler-discovery.ts +366 -0
- package/cli/utils/json-utils.ts +24 -0
- package/cli/utils/path-utils.ts +19 -0
- package/cli/utils/schema-discovery.ts +390 -0
- package/index.ts +27 -4
- package/package.json +23 -20
- package/react/index.ts +5 -3
- package/react/use-infinite-query.ts +190 -0
- package/react/use-mutation.ts +54 -0
- package/react/use-query.ts +158 -0
- package/schema.ts +262 -0
- package/tsconfig.json +2 -4
- package/cli/README.md +0 -108
- package/cli/core/build.ts +0 -187
- package/cli/core/config.ts +0 -92
- package/cli/core/discover-handlers.ts +0 -143
- package/cli/core/handlers.ts +0 -7
- package/cli/core/index.ts +0 -205
- package/cli/generators/generate-api-client/client.ts +0 -163
- package/cli/generators/generate-api-client/extract-configuration.ts +0 -121
- package/cli/generators/generate-api-client/index.ts +0 -973
- package/cli/generators/generate-api-client/types.ts +0 -164
- package/cli/generators/generate-api-client/utils.ts +0 -22
- package/cli/generators/generate-api-client.ts +0 -1
- package/cli/generators/generate-cloudflare-worker/helpers.ts +0 -24
- package/cli/generators/generate-cloudflare-worker/index.ts +0 -2
- package/cli/generators/generate-cloudflare-worker/worker.ts +0 -148
- package/cli/generators/generate-cloudflare-worker/wrangler.ts +0 -108
- package/cli/generators/generate-cloudflare-worker.ts +0 -4
- package/cli/generators/generate-cron-handlers/cron-handlers-block.ts +0 -2
- package/cli/generators/generate-cron-handlers/handler-entries.ts +0 -29
- package/cli/generators/generate-cron-handlers/index.ts +0 -61
- package/cli/generators/generate-cron-handlers/runtime-block.ts +0 -49
- package/cli/generators/generate-cron-handlers/type-helpers-block.ts +0 -60
- package/cli/generators/generate-db-handlers/index.ts +0 -33
- package/cli/generators/generate-db-handlers/prepare.ts +0 -24
- package/cli/generators/generate-db-handlers/templates.ts +0 -189
- package/cli/generators/generate-db-handlers.ts +0 -1
- package/cli/generators/generate-hono-server/auth.ts +0 -97
- package/cli/generators/generate-hono-server/imports.ts +0 -55
- package/cli/generators/generate-hono-server/index.ts +0 -52
- package/cli/generators/generate-hono-server/routes.ts +0 -115
- package/cli/generators/generate-hono-server/template.ts +0 -371
- package/cli/generators/generate-hono-server.ts +0 -1
- package/cli/generators/generate-scheduler-handlers/constants.ts +0 -8
- package/cli/generators/generate-scheduler-handlers/handler-entries.ts +0 -22
- package/cli/generators/generate-scheduler-handlers/index.ts +0 -51
- package/cli/generators/generate-scheduler-handlers/runtime-block.ts +0 -68
- package/cli/generators/generate-scheduler-handlers/scheduler-handlers-block.ts +0 -2
- package/cli/generators/generate-scheduler-handlers/type-helpers-block.ts +0 -68
- package/cli/generators/generate-scheduler-handlers.ts +0 -1
- package/cli/generators/generate-websocket-durable-object/auth.ts +0 -30
- package/cli/generators/generate-websocket-durable-object/imports.ts +0 -55
- package/cli/generators/generate-websocket-durable-object/index.ts +0 -41
- package/cli/generators/generate-websocket-durable-object/query-handlers.ts +0 -18
- package/cli/generators/generate-websocket-durable-object/template.ts +0 -714
- package/cli/generators/generate-websocket-durable-object.ts +0 -1
- package/cli/schema/schema-static-types.ts +0 -702
- package/cli/schema/schema.ts +0 -151
- package/cli/utils/tsc.ts +0 -54
- package/cli/utils/utils.ts +0 -190
- package/cli/utils/zod-utils.ts +0 -121
- package/lib/README.md +0 -50
- package/lib/db.ts +0 -19
- package/lib/location.ts +0 -110
- package/lib/values.ts +0 -27
- package/react/README.md +0 -67
- package/react/hooks/useMutation.ts +0 -89
- package/react/hooks/usePaginatedQuery.ts +0 -213
- package/react/hooks/useQuery.ts +0 -106
- package/react/shared/queryShared.ts +0 -174
- package/server/README.md +0 -218
- package/server/auth.ts +0 -107
- package/server/database/builders.ts +0 -83
- package/server/database/context.ts +0 -327
- package/server/database/populate.ts +0 -234
- package/server/database/query-builder.ts +0 -161
- package/server/database/query-utils.ts +0 -25
- package/server/db.ts +0 -2
- package/server/storage/auth.ts +0 -16
- package/server/storage/bucket.ts +0 -22
- package/server/storage/context.ts +0 -34
- package/server/storage/index.ts +0 -38
- package/server/storage/operations.ts +0 -149
- package/server/storage/route-handler.ts +0 -60
- package/server/storage/types.ts +0 -55
- package/server/storage/utils.ts +0 -47
- package/server/storage.ts +0 -6
- package/server/types/schema-refs.ts +0 -66
- package/server/types/types.ts +0 -633
- 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
|
+
}
|