@chidchanun/bcp 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,493 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ pathToFileURL,
5
+ } from "node:url";
6
+
7
+ import {
8
+ executePageGuards,
9
+ type PageGuardData,
10
+ } from "./page-guard.js";
11
+
12
+ export const PAGE_ACTION_METHODS = [
13
+ "POST",
14
+ "PUT",
15
+ "PATCH",
16
+ "DELETE",
17
+ ] as const;
18
+
19
+ export type PageActionMethod =
20
+ typeof PAGE_ACTION_METHODS[number];
21
+
22
+ export interface PageActionContext {
23
+ params: Record<string, any>;
24
+ searchParams: URLSearchParams;
25
+ guardData: Readonly<PageGuardData>;
26
+ method: PageActionMethod;
27
+ }
28
+
29
+ export type PageAction = (
30
+ formData: FormData,
31
+ context: PageActionContext
32
+ ) =>
33
+ | unknown
34
+ | Promise<unknown>;
35
+
36
+ export interface PageActionExecution {
37
+ hasGuard: boolean;
38
+ guardData: PageGuardData;
39
+ data: unknown;
40
+ response: Response | null;
41
+ }
42
+
43
+ const ACTION_NAME_PATTERN =
44
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/;
45
+
46
+ const BLOCKED_ACTION_NAMES =
47
+ new Set([
48
+ "default",
49
+ "__proto__",
50
+ "prototype",
51
+ "constructor",
52
+ ]);
53
+
54
+ export function findPageActionFile(
55
+ pageFilePath: string
56
+ ): string | null {
57
+ const directory =
58
+ path.dirname(
59
+ pageFilePath
60
+ );
61
+ const tsFile =
62
+ path.join(
63
+ directory,
64
+ "actions.ts"
65
+ );
66
+ const tsxFile =
67
+ path.join(
68
+ directory,
69
+ "actions.tsx"
70
+ );
71
+ const hasTs =
72
+ fs.existsSync(
73
+ tsFile
74
+ );
75
+ const hasTsx =
76
+ fs.existsSync(
77
+ tsxFile
78
+ );
79
+
80
+ if (hasTs && hasTsx) {
81
+ throw new Error(
82
+ `BCP Framework: both actions.ts and actions.tsx exist in "${directory}".`
83
+ );
84
+ }
85
+
86
+ if (hasTs) {
87
+ return tsFile;
88
+ }
89
+
90
+ if (hasTsx) {
91
+ return tsxFile;
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ export function routeHasPageActions(
98
+ pageFilePath: string
99
+ ): boolean {
100
+ return findPageActionFile(
101
+ pageFilePath
102
+ ) !== null;
103
+ }
104
+
105
+ export function normalizePageActionMethod(
106
+ value: string | null | undefined
107
+ ): PageActionMethod {
108
+ const method =
109
+ (value ?? "POST")
110
+ .trim()
111
+ .toUpperCase();
112
+
113
+ if (
114
+ !PAGE_ACTION_METHODS.includes(
115
+ method as PageActionMethod
116
+ )
117
+ ) {
118
+ throw new Error(
119
+ `BCP Framework: unsupported form action method "${method || String(value)}". Use POST, PUT, PATCH, or DELETE.`
120
+ );
121
+ }
122
+
123
+ return method as PageActionMethod;
124
+ }
125
+
126
+ export function assertPageActionName(
127
+ name: string
128
+ ): void {
129
+ if (
130
+ !ACTION_NAME_PATTERN.test(
131
+ name
132
+ ) ||
133
+ BLOCKED_ACTION_NAMES.has(
134
+ name
135
+ )
136
+ ) {
137
+ throw new Error(
138
+ `BCP Framework: invalid form action name "${name}".`
139
+ );
140
+ }
141
+ }
142
+
143
+ export async function executePageAction(
144
+ pageFilePath: string,
145
+ actionName: string,
146
+ method: PageActionMethod,
147
+ formData: FormData,
148
+ params: Record<string, any>,
149
+ requestUrl: URL,
150
+ moduleVersion = 0
151
+ ): Promise<PageActionExecution> {
152
+ assertPageActionName(
153
+ actionName
154
+ );
155
+
156
+ const appDirectory =
157
+ findAppDirectory(
158
+ pageFilePath
159
+ );
160
+ const guardExecution =
161
+ await executePageGuards(
162
+ appDirectory,
163
+ pageFilePath,
164
+ params,
165
+ requestUrl,
166
+ moduleVersion
167
+ );
168
+
169
+ if (
170
+ guardExecution.response
171
+ ) {
172
+ return {
173
+ hasGuard:
174
+ guardExecution.hasGuard,
175
+ guardData: {
176
+ ...guardExecution.data,
177
+ },
178
+ data:
179
+ null,
180
+ response:
181
+ guardExecution.response,
182
+ };
183
+ }
184
+
185
+ const actionFile =
186
+ findPageActionFile(
187
+ pageFilePath
188
+ );
189
+
190
+ if (!actionFile) {
191
+ throw new Error(
192
+ `BCP Framework: route "${pageFilePath}" has no actions.ts file.`
193
+ );
194
+ }
195
+
196
+ const moduleUrl =
197
+ pathToFileURL(
198
+ actionFile
199
+ );
200
+
201
+ moduleUrl.searchParams.set(
202
+ "bcp-action",
203
+ String(
204
+ moduleVersion
205
+ )
206
+ );
207
+
208
+ const actionModule =
209
+ await import(
210
+ moduleUrl.href
211
+ ) as Record<
212
+ string,
213
+ unknown
214
+ >;
215
+ const candidate =
216
+ actionModule[
217
+ actionName
218
+ ];
219
+
220
+ if (
221
+ typeof candidate !==
222
+ "function"
223
+ ) {
224
+ throw new Error(
225
+ `BCP Framework: action "${actionName}" was not exported as a function from "${actionFile}".`
226
+ );
227
+ }
228
+
229
+ return executePageActionFunction(
230
+ candidate as PageAction,
231
+ actionName,
232
+ method,
233
+ formData,
234
+ params,
235
+ requestUrl,
236
+ guardExecution.data,
237
+ guardExecution.hasGuard,
238
+ actionFile
239
+ );
240
+ }
241
+
242
+ export async function executePageActionFunction(
243
+ action: PageAction,
244
+ actionName: string,
245
+ method: PageActionMethod,
246
+ formData: FormData,
247
+ params: Record<string, any>,
248
+ requestUrl: URL,
249
+ guardData: PageGuardData = {},
250
+ hasGuard = false,
251
+ label = "actions.ts"
252
+ ): Promise<PageActionExecution> {
253
+ assertPageActionName(
254
+ actionName
255
+ );
256
+
257
+ const result =
258
+ await action(
259
+ formData,
260
+ {
261
+ params: {
262
+ ...params,
263
+ },
264
+ searchParams:
265
+ new URLSearchParams(
266
+ requestUrl.searchParams
267
+ ),
268
+ guardData: {
269
+ ...guardData,
270
+ },
271
+ method,
272
+ }
273
+ );
274
+
275
+ if (
276
+ result instanceof
277
+ Response
278
+ ) {
279
+ return {
280
+ hasGuard,
281
+ guardData: {
282
+ ...guardData,
283
+ },
284
+ data:
285
+ null,
286
+ response:
287
+ result,
288
+ };
289
+ }
290
+
291
+ const data =
292
+ result === undefined
293
+ ? null
294
+ : result;
295
+
296
+ assertActionData(
297
+ data,
298
+ `${label}#${actionName}`
299
+ );
300
+
301
+ return {
302
+ hasGuard,
303
+ guardData: {
304
+ ...guardData,
305
+ },
306
+ data,
307
+ response:
308
+ null,
309
+ };
310
+ }
311
+
312
+ export function assertActionData(
313
+ value: unknown,
314
+ actionLabel = "action"
315
+ ): void {
316
+ const seen =
317
+ new Set<object>();
318
+
319
+ validateJsonValue(
320
+ value,
321
+ "$",
322
+ seen,
323
+ actionLabel
324
+ );
325
+ }
326
+
327
+ function findAppDirectory(
328
+ pageFilePath: string
329
+ ): string {
330
+ let current =
331
+ path.dirname(
332
+ path.resolve(
333
+ pageFilePath
334
+ )
335
+ );
336
+
337
+ while (true) {
338
+ if (
339
+ path.basename(
340
+ current
341
+ ) === "app"
342
+ ) {
343
+ return current;
344
+ }
345
+
346
+ const parent =
347
+ path.dirname(
348
+ current
349
+ );
350
+
351
+ if (parent === current) {
352
+ throw new Error(
353
+ `BCP Framework: could not resolve app directory for page "${pageFilePath}".`
354
+ );
355
+ }
356
+
357
+ current =
358
+ parent;
359
+ }
360
+ }
361
+
362
+ function validateJsonValue(
363
+ value: unknown,
364
+ location: string,
365
+ seen: Set<object>,
366
+ actionLabel: string
367
+ ): void {
368
+ if (
369
+ value === null ||
370
+ typeof value ===
371
+ "string" ||
372
+ typeof value ===
373
+ "boolean"
374
+ ) {
375
+ return;
376
+ }
377
+
378
+ if (
379
+ typeof value ===
380
+ "number"
381
+ ) {
382
+ if (
383
+ Number.isFinite(
384
+ value
385
+ )
386
+ ) {
387
+ return;
388
+ }
389
+
390
+ throwInvalidActionData(
391
+ actionLabel,
392
+ location,
393
+ "numbers must be finite"
394
+ );
395
+ }
396
+
397
+ if (
398
+ typeof value !==
399
+ "object"
400
+ ) {
401
+ throwInvalidActionData(
402
+ actionLabel,
403
+ location,
404
+ `unsupported ${typeof value} value`
405
+ );
406
+ }
407
+
408
+ const objectValue =
409
+ value as object;
410
+
411
+ if (seen.has(objectValue)) {
412
+ throwInvalidActionData(
413
+ actionLabel,
414
+ location,
415
+ "circular references are not supported"
416
+ );
417
+ }
418
+
419
+ seen.add(
420
+ objectValue
421
+ );
422
+
423
+ try {
424
+ if (Array.isArray(value)) {
425
+ value.forEach(
426
+ (
427
+ item,
428
+ index
429
+ ) => {
430
+ validateJsonValue(
431
+ item,
432
+ `${location}[${index}]`,
433
+ seen,
434
+ actionLabel
435
+ );
436
+ }
437
+ );
438
+ return;
439
+ }
440
+
441
+ const prototype =
442
+ Object.getPrototypeOf(
443
+ value
444
+ );
445
+
446
+ if (
447
+ prototype !==
448
+ Object.prototype &&
449
+ prototype !==
450
+ null
451
+ ) {
452
+ throwInvalidActionData(
453
+ actionLabel,
454
+ location,
455
+ "only plain objects and arrays are supported"
456
+ );
457
+ }
458
+
459
+ for (
460
+ const [
461
+ key,
462
+ item,
463
+ ]
464
+ of Object.entries(
465
+ value as Record<
466
+ string,
467
+ unknown
468
+ >
469
+ )
470
+ ) {
471
+ validateJsonValue(
472
+ item,
473
+ `${location}.${key}`,
474
+ seen,
475
+ actionLabel
476
+ );
477
+ }
478
+ } finally {
479
+ seen.delete(
480
+ objectValue
481
+ );
482
+ }
483
+ }
484
+
485
+ function throwInvalidActionData(
486
+ actionLabel: string,
487
+ location: string,
488
+ reason: string
489
+ ): never {
490
+ throw new Error(
491
+ `BCP Framework: action "${actionLabel}" returned non-serializable data at ${location}: ${reason}.`
492
+ );
493
+ }
@@ -9,6 +9,10 @@ import {
9
9
  createCriticalCssProxy,
10
10
  } from "./critical-css-proxy.js";
11
11
 
12
+ import {
13
+ createFormActionDevProxy,
14
+ } from "./form-action-dev-proxy.js";
15
+
12
16
  import {
13
17
  loadProjectMiddleware,
14
18
  } from "./middleware-loader.js";
@@ -48,6 +52,12 @@ export function createMiddlewareDevServer(
48
52
  > | null =
49
53
  null;
50
54
 
55
+ let actionGateway:
56
+ ReturnType<
57
+ typeof createFormActionDevProxy
58
+ > | null =
59
+ null;
60
+
51
61
  let middlewareGateway:
52
62
  ReturnType<
53
63
  typeof createMiddlewareProxy
@@ -76,6 +86,8 @@ export function createMiddlewareDevServer(
76
86
 
77
87
  const applicationPort =
78
88
  await findFreePort();
89
+ const actionPort =
90
+ await findFreePort();
79
91
  const middlewarePort =
80
92
  await findFreePort();
81
93
  const securityPort =
@@ -93,6 +105,30 @@ export function createMiddlewareDevServer(
93
105
 
94
106
  await internalServer.start();
95
107
 
108
+ actionGateway =
109
+ createFormActionDevProxy({
110
+ port:
111
+ actionPort,
112
+ hostname:
113
+ "127.0.0.1",
114
+ upstreamPort:
115
+ applicationPort,
116
+ upstreamHostname:
117
+ "127.0.0.1",
118
+ rootDirectory,
119
+ });
120
+
121
+ try {
122
+ await actionGateway.start();
123
+ } catch (error) {
124
+ await internalServer.stop();
125
+ internalServer =
126
+ null;
127
+ actionGateway =
128
+ null;
129
+ throw error;
130
+ }
131
+
96
132
  middlewareGateway =
97
133
  createMiddlewareProxy({
98
134
  port:
@@ -100,7 +136,7 @@ export function createMiddlewareDevServer(
100
136
  hostname:
101
137
  "127.0.0.1",
102
138
  upstreamPort:
103
- applicationPort,
139
+ actionPort,
104
140
  upstreamHostname:
105
141
  "127.0.0.1",
106
142
  getMiddleware:
@@ -113,9 +149,12 @@ export function createMiddlewareDevServer(
113
149
  try {
114
150
  await middlewareGateway.start();
115
151
  } catch (error) {
152
+ await actionGateway.stop();
116
153
  await internalServer.stop();
117
154
  internalServer =
118
155
  null;
156
+ actionGateway =
157
+ null;
119
158
  middlewareGateway =
120
159
  null;
121
160
  throw error;
@@ -137,9 +176,12 @@ export function createMiddlewareDevServer(
137
176
  await securityGateway.start();
138
177
  } catch (error) {
139
178
  await middlewareGateway.stop();
179
+ await actionGateway.stop();
140
180
  await internalServer.stop();
141
181
  internalServer =
142
182
  null;
183
+ actionGateway =
184
+ null;
143
185
  middlewareGateway =
144
186
  null;
145
187
  securityGateway =
@@ -168,9 +210,12 @@ export function createMiddlewareDevServer(
168
210
  } catch (error) {
169
211
  await securityGateway.stop();
170
212
  await middlewareGateway.stop();
213
+ await actionGateway.stop();
171
214
  await internalServer.stop();
172
215
  internalServer =
173
216
  null;
217
+ actionGateway =
218
+ null;
174
219
  middlewareGateway =
175
220
  null;
176
221
  securityGateway =
@@ -186,6 +231,9 @@ export function createMiddlewareDevServer(
186
231
  console.log(
187
232
  `[BCP Security] Dev gateway: http://${hostname}:${port}`
188
233
  );
234
+ console.log(
235
+ `[BCP Actions] Endpoint: /_bcp/action`
236
+ );
189
237
  console.log(
190
238
  `[BCP CSS] Inline /bcp.css when <= 8 KiB and allowed by CSP.`
191
239
  );
@@ -214,6 +262,12 @@ export function createMiddlewareDevServer(
214
262
  null;
215
263
  }
216
264
 
265
+ if (actionGateway) {
266
+ await actionGateway.stop();
267
+ actionGateway =
268
+ null;
269
+ }
270
+
217
271
  if (internalServer) {
218
272
  await internalServer.stop();
219
273
  internalServer =
@@ -313,4 +367,4 @@ async function findFreePort(): Promise<number> {
313
367
  );
314
368
  }
315
369
  }
316
- }
370
+ }