@beignet/core 0.0.7 → 0.0.9

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.
@@ -19,6 +19,14 @@ export type ProviderPackageRegistrationMetadata = {
19
19
  tokens?: readonly string[];
20
20
  };
21
21
 
22
+ export type ProviderPackageVariantMetadata = {
23
+ name: string;
24
+ displayName?: string;
25
+ env?: readonly string[];
26
+ requiredEnv?: readonly string[];
27
+ registration?: ProviderPackageRegistrationMetadata;
28
+ };
29
+
22
30
  export type ProviderPackageMetadata = {
23
31
  displayName?: string;
24
32
  ports?: readonly string[];
@@ -26,6 +34,7 @@ export type ProviderPackageMetadata = {
26
34
  env?: readonly string[];
27
35
  requiredEnv?: readonly string[];
28
36
  registration?: ProviderPackageRegistrationMetadata;
37
+ variants?: readonly ProviderPackageVariantMetadata[];
29
38
  watchers?: readonly string[];
30
39
  };
31
40
 
@@ -101,9 +110,33 @@ export function parseProviderPackageMetadata(
101
110
  const appPorts = parseAppPorts(input.appPorts, issues);
102
111
  if (appPorts !== undefined) metadata.appPorts = appPorts;
103
112
 
104
- const registration = parseRegistration(input.registration, issues);
113
+ const registration = parseRegistration(
114
+ input.registration,
115
+ issues,
116
+ "beignet.provider.registration",
117
+ );
105
118
  if (registration !== undefined) metadata.registration = registration;
106
119
 
120
+ const variants = parseVariants(input.variants, issues);
121
+ if (variants !== undefined) metadata.variants = variants;
122
+
123
+ if (input.variants !== undefined) {
124
+ if (input.requiredEnv !== undefined) {
125
+ issues.push({
126
+ path: "beignet.provider.requiredEnv",
127
+ message:
128
+ "must not be set when beignet.provider.variants is present; declare requiredEnv on each variant instead",
129
+ });
130
+ }
131
+ if (input.registration !== undefined) {
132
+ issues.push({
133
+ path: "beignet.provider.registration",
134
+ message:
135
+ "must not be set when beignet.provider.variants is present; declare registration on each variant instead",
136
+ });
137
+ }
138
+ }
139
+
107
140
  if (issues.length > 0) {
108
141
  return { success: false, issues };
109
142
  }
@@ -169,12 +202,103 @@ function parseAppPorts(
169
202
  return parsed;
170
203
  }
171
204
 
205
+ function parseVariants(
206
+ value: unknown,
207
+ issues: ProviderPackageMetadataIssue[],
208
+ ): ProviderPackageVariantMetadata[] | undefined {
209
+ if (value === undefined) return undefined;
210
+ const path = "beignet.provider.variants";
211
+ if (!Array.isArray(value)) {
212
+ issues.push({ path, message: "must be an array of variant objects" });
213
+ return undefined;
214
+ }
215
+ if (value.length === 0) {
216
+ issues.push({
217
+ path,
218
+ message: "must include at least one variant when present",
219
+ });
220
+ return undefined;
221
+ }
222
+
223
+ const parsed: ProviderPackageVariantMetadata[] = [];
224
+ const seenNames = new Set<string>();
225
+ for (const [index, entry] of value.entries()) {
226
+ const entryPath = `${path}[${index}]`;
227
+ if (!isRecord(entry)) {
228
+ issues.push({ path: entryPath, message: "must be an object" });
229
+ continue;
230
+ }
231
+
232
+ let name: string | undefined;
233
+ if (typeof entry.name !== "string" || entry.name.length === 0) {
234
+ issues.push({
235
+ path: `${entryPath}.name`,
236
+ message: "must be a non-empty string",
237
+ });
238
+ } else if (seenNames.has(entry.name)) {
239
+ issues.push({
240
+ path: `${entryPath}.name`,
241
+ message: `duplicates variant name "${entry.name}"`,
242
+ });
243
+ } else {
244
+ seenNames.add(entry.name);
245
+ name = entry.name;
246
+ }
247
+
248
+ let displayName: string | undefined;
249
+ if (entry.displayName !== undefined) {
250
+ if (typeof entry.displayName !== "string") {
251
+ issues.push({
252
+ path: `${entryPath}.displayName`,
253
+ message: "must be a string",
254
+ });
255
+ } else {
256
+ displayName = entry.displayName;
257
+ }
258
+ }
259
+
260
+ const env = parseStringArray(entry.env, `${entryPath}.env`, issues);
261
+ const requiredEnv = parseStringArray(
262
+ entry.requiredEnv,
263
+ `${entryPath}.requiredEnv`,
264
+ issues,
265
+ );
266
+ if (requiredEnv !== undefined) {
267
+ const envSet = new Set(env ?? []);
268
+ for (const envVar of requiredEnv) {
269
+ if (!envSet.has(envVar)) {
270
+ issues.push({
271
+ path: `${entryPath}.requiredEnv`,
272
+ message: `${envVar} must also be listed in ${entryPath}.env`,
273
+ });
274
+ }
275
+ }
276
+ }
277
+
278
+ const registration = parseRegistration(
279
+ entry.registration,
280
+ issues,
281
+ `${entryPath}.registration`,
282
+ );
283
+
284
+ if (name === undefined) continue;
285
+ const variant: ProviderPackageVariantMetadata = { name };
286
+ if (displayName !== undefined) variant.displayName = displayName;
287
+ if (env !== undefined) variant.env = env;
288
+ if (requiredEnv !== undefined) variant.requiredEnv = requiredEnv;
289
+ if (registration !== undefined) variant.registration = registration;
290
+ parsed.push(variant);
291
+ }
292
+
293
+ return parsed;
294
+ }
295
+
172
296
  function parseRegistration(
173
297
  value: unknown,
174
298
  issues: ProviderPackageMetadataIssue[],
299
+ path: string,
175
300
  ): ProviderPackageRegistrationMetadata | undefined {
176
301
  if (value === undefined) return undefined;
177
- const path = "beignet.provider.registration";
178
302
  if (!isRecord(value)) {
179
303
  issues.push({ path, message: "must be an object" });
180
304
  return undefined;
@@ -541,9 +541,15 @@ export function createMemoryUploadSigner(
541
541
  export function createUploadRouter<Ctx>(
542
542
  options: CreateUploadRouterOptions<Ctx>,
543
543
  ): UploadRouter {
544
- const uploads = new Map(
545
- options.uploads.map((upload) => [upload.name, upload]),
546
- );
544
+ const uploads = new Map<string, UploadDef<string, StandardSchema, Ctx>>();
545
+ for (const upload of options.uploads) {
546
+ if (uploads.has(upload.name)) {
547
+ throw new Error(
548
+ `createUploadRouter received duplicate upload name "${upload.name}". Each defineUpload(...) name must be unique.`,
549
+ );
550
+ }
551
+ uploads.set(upload.name, upload);
552
+ }
547
553
  const id = options.id ?? randomUploadId;
548
554
  const instrumentation = createProviderInstrumentation(
549
555
  options.instrumentation,
@@ -562,10 +568,13 @@ export function createUploadRouter<Ctx>(
562
568
  function findUpload(name: string) {
563
569
  const upload = uploads.get(name);
564
570
  if (!upload) {
571
+ const registered = [...uploads.keys()]
572
+ .map((registeredName) => `"${registeredName}"`)
573
+ .join(", ");
565
574
  throw new UploadError({
566
575
  code: "UPLOAD_NOT_FOUND",
567
576
  status: 404,
568
- message: `Upload "${name}" is not registered.`,
577
+ message: `Upload "${name}" is not registered. Registered uploads: ${registered || "none"}. Upload routes resolve the defineUpload(...) name, not the defineUploads({...}) registry key.`,
569
578
  });
570
579
  }
571
580
  return upload;
@@ -581,12 +590,13 @@ export function createUploadRouter<Ctx>(
581
590
 
582
591
  try {
583
592
  const upload = findUpload(uploadName);
593
+ const parsed = parsePrepareInput(uploadName, input);
584
594
  const ctx = await resolveCtx();
585
- const metadata = await parseMetadata(upload, input.metadata);
586
- assertFiles(upload, input.files);
595
+ const metadata = await parseMetadata(upload, parsed.metadata);
596
+ assertFiles(upload, parsed.files);
587
597
 
588
598
  const files: PreparedUploadResultFile[] = [];
589
- for (const file of input.files) {
599
+ for (const file of parsed.files) {
590
600
  const uploadId = id();
591
601
  await assertAuthorized(upload, { ctx, metadata, file, uploadId });
592
602
  const key = await upload.key({ ctx, metadata, file, uploadId });
@@ -652,12 +662,13 @@ export function createUploadRouter<Ctx>(
652
662
 
653
663
  try {
654
664
  const upload = findUpload(uploadName);
665
+ const parsed = parseCompleteInput(uploadName, input);
655
666
  const ctx = await resolveCtx();
656
- const metadata = await parseMetadata(upload, input.metadata);
657
- assertFiles(upload, input.files);
667
+ const metadata = await parseMetadata(upload, parsed.metadata);
668
+ assertFiles(upload, parsed.files);
658
669
 
659
670
  const files: CompletedUploadFile[] = [];
660
- for (const file of input.files) {
671
+ for (const file of parsed.files) {
661
672
  await assertAuthorized(upload, {
662
673
  ctx,
663
674
  metadata,
@@ -827,13 +838,29 @@ export function createUploadRouter<Ctx>(
827
838
  async handleRequest(request, requestOptions) {
828
839
  try {
829
840
  if (requestOptions.action === "prepare") {
830
- const input = (await request.json()) as PrepareUploadInput;
831
- return jsonResponse(await prepare(requestOptions.uploadName, input));
841
+ const input = await readJsonBody(request, {
842
+ uploadName: requestOptions.uploadName,
843
+ action: "prepare",
844
+ });
845
+ return jsonResponse(
846
+ await prepare(
847
+ requestOptions.uploadName,
848
+ input as PrepareUploadInput,
849
+ ),
850
+ );
832
851
  }
833
852
 
834
853
  if (requestOptions.action === "complete") {
835
- const input = (await request.json()) as CompleteUploadInput;
836
- return jsonResponse(await complete(requestOptions.uploadName, input));
854
+ const input = await readJsonBody(request, {
855
+ uploadName: requestOptions.uploadName,
856
+ action: "complete",
857
+ });
858
+ return jsonResponse(
859
+ await complete(
860
+ requestOptions.uploadName,
861
+ input as CompleteUploadInput,
862
+ ),
863
+ );
837
864
  }
838
865
 
839
866
  const formData = await request.formData();
@@ -849,6 +876,104 @@ export function createUploadRouter<Ctx>(
849
876
  };
850
877
  }
851
878
 
879
+ async function readJsonBody(
880
+ request: Request,
881
+ context: { uploadName: string; action: "prepare" | "complete" },
882
+ ): Promise<unknown> {
883
+ try {
884
+ return await request.json();
885
+ } catch {
886
+ throw new UploadError({
887
+ code: "INVALID_UPLOAD_BODY",
888
+ status: 400,
889
+ message: `Upload "${context.uploadName}" ${context.action} body must be valid JSON.`,
890
+ });
891
+ }
892
+ }
893
+
894
+ interface UploadBodyIssue {
895
+ message: string;
896
+ path: (string | number)[];
897
+ }
898
+
899
+ function collectBodyIssues(
900
+ body: unknown,
901
+ options: { requireCompletedFileFields: boolean },
902
+ ): UploadBodyIssue[] {
903
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
904
+ return [{ message: "Body must be a JSON object.", path: [] }];
905
+ }
906
+
907
+ const files = (body as { files?: unknown }).files;
908
+ if (!Array.isArray(files)) {
909
+ return [{ message: 'Body must include a "files" array.', path: ["files"] }];
910
+ }
911
+
912
+ const issues: UploadBodyIssue[] = [];
913
+ for (const [index, file] of files.entries()) {
914
+ if (typeof file !== "object" || file === null || Array.isArray(file)) {
915
+ issues.push({
916
+ message: "Each file must be an object.",
917
+ path: ["files", index],
918
+ });
919
+ continue;
920
+ }
921
+
922
+ if (!options.requireCompletedFileFields) continue;
923
+
924
+ const completed = file as { uploadId?: unknown; key?: unknown };
925
+ if (typeof completed.uploadId !== "string") {
926
+ issues.push({
927
+ message: 'Each completed file must include a string "uploadId".',
928
+ path: ["files", index, "uploadId"],
929
+ });
930
+ }
931
+ if (typeof completed.key !== "string") {
932
+ issues.push({
933
+ message: 'Each completed file must include a string "key".',
934
+ path: ["files", index, "key"],
935
+ });
936
+ }
937
+ }
938
+ return issues;
939
+ }
940
+
941
+ function parsePrepareInput(
942
+ uploadName: string,
943
+ body: unknown,
944
+ ): PrepareUploadInput {
945
+ const issues = collectBodyIssues(body, {
946
+ requireCompletedFileFields: false,
947
+ });
948
+ if (issues.length > 0) {
949
+ throw new UploadError({
950
+ code: "INVALID_UPLOAD_BODY",
951
+ status: 400,
952
+ message: `Upload "${uploadName}" prepare body is invalid.`,
953
+ details: { issues },
954
+ });
955
+ }
956
+ return body as PrepareUploadInput;
957
+ }
958
+
959
+ function parseCompleteInput(
960
+ uploadName: string,
961
+ body: unknown,
962
+ ): CompleteUploadInput {
963
+ const issues = collectBodyIssues(body, {
964
+ requireCompletedFileFields: true,
965
+ });
966
+ if (issues.length > 0) {
967
+ throw new UploadError({
968
+ code: "INVALID_UPLOAD_BODY",
969
+ status: 400,
970
+ message: `Upload "${uploadName}" complete body is invalid.`,
971
+ details: { issues },
972
+ });
973
+ }
974
+ return body as CompleteUploadInput;
975
+ }
976
+
852
977
  async function parseMetadata<U extends UploadDef>(
853
978
  upload: U,
854
979
  input: unknown,