@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.
- package/CHANGELOG.md +83 -1
- package/README.md +263 -8
- package/docs/form-actions.md +314 -0
- package/docs/releasing.md +34 -19
- package/docs/route-guards.md +240 -0
- package/docs/updating.md +130 -0
- package/package.json +1 -1
- package/packages/bundler/src/server-production-actions.ts +523 -0
- package/packages/bundler/src/server-production-guards.ts +497 -0
- package/packages/bundler/src/server-production-middleware.ts +50 -8
- package/packages/cli/src/args.ts +58 -0
- package/packages/cli/src/index.ts +41 -13
- package/packages/cli/src/update.ts +860 -0
- package/packages/client/src/form.tsx +548 -0
- package/packages/client/src/index.tsx +11 -0
- package/packages/client/src/loader-data.tsx +171 -41
- package/packages/client/src/server.ts +6 -0
- package/packages/server/src/form-action-dev-proxy.ts +601 -0
- package/packages/server/src/form-action-transport.ts +271 -0
- package/packages/server/src/form-action.ts +493 -0
- package/packages/server/src/middleware-dev-server.ts +56 -2
- package/packages/server/src/page-guard.ts +529 -0
- package/packages/server/src/page-loader.ts +317 -20
- package/packages/server/src/standalone-production-runtime-v2-action.ts +765 -0
- package/packages/server/src/standalone-production-runtime-v2-guard.ts +815 -0
- package/packages/server/src/standalone-production-runtime-v3.ts +1 -1
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
type FormHTMLAttributes,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
navigate,
|
|
12
|
+
useRouter,
|
|
13
|
+
} from "./router-v2.js";
|
|
14
|
+
|
|
15
|
+
type FormSubmitEvent =
|
|
16
|
+
Parameters<
|
|
17
|
+
NonNullable<
|
|
18
|
+
FormHTMLAttributes<
|
|
19
|
+
HTMLFormElement
|
|
20
|
+
>["onSubmit"]
|
|
21
|
+
>
|
|
22
|
+
>[0];
|
|
23
|
+
|
|
24
|
+
export type FormActionMethod =
|
|
25
|
+
| "post"
|
|
26
|
+
| "put"
|
|
27
|
+
| "patch"
|
|
28
|
+
| "delete";
|
|
29
|
+
|
|
30
|
+
export interface FormStatus {
|
|
31
|
+
pending: boolean;
|
|
32
|
+
action: string | null;
|
|
33
|
+
method: FormActionMethod | null;
|
|
34
|
+
error: Error | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FormProps
|
|
38
|
+
extends Omit<
|
|
39
|
+
FormHTMLAttributes<HTMLFormElement>,
|
|
40
|
+
"action" | "method"
|
|
41
|
+
> {
|
|
42
|
+
action: string;
|
|
43
|
+
method?: FormActionMethod;
|
|
44
|
+
refresh?: boolean;
|
|
45
|
+
replace?: boolean;
|
|
46
|
+
scroll?: boolean;
|
|
47
|
+
onActionData?: (
|
|
48
|
+
data: unknown
|
|
49
|
+
) => void;
|
|
50
|
+
onActionError?: (
|
|
51
|
+
error: Error
|
|
52
|
+
) => void;
|
|
53
|
+
children?: ReactNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface FormRuntimeState {
|
|
57
|
+
status: FormStatus;
|
|
58
|
+
actionData: unknown;
|
|
59
|
+
hasActionData: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ActionDataPayload {
|
|
63
|
+
kind: "data";
|
|
64
|
+
data: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ActionRedirectPayload {
|
|
68
|
+
kind: "redirect";
|
|
69
|
+
redirect: {
|
|
70
|
+
location: string;
|
|
71
|
+
status: number;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ActionErrorPayload {
|
|
76
|
+
kind: "error";
|
|
77
|
+
error: {
|
|
78
|
+
message: string;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ActionPayload =
|
|
83
|
+
| ActionDataPayload
|
|
84
|
+
| ActionRedirectPayload
|
|
85
|
+
| ActionErrorPayload;
|
|
86
|
+
|
|
87
|
+
const ACTION_NAME_PATTERN =
|
|
88
|
+
/^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
89
|
+
|
|
90
|
+
const FormRuntimeContext =
|
|
91
|
+
createContext<FormRuntimeState>({
|
|
92
|
+
status: {
|
|
93
|
+
pending: false,
|
|
94
|
+
action: null,
|
|
95
|
+
method: null,
|
|
96
|
+
error: null,
|
|
97
|
+
},
|
|
98
|
+
actionData:
|
|
99
|
+
undefined,
|
|
100
|
+
hasActionData:
|
|
101
|
+
false,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export function Form({
|
|
105
|
+
action,
|
|
106
|
+
method = "post",
|
|
107
|
+
refresh = false,
|
|
108
|
+
replace = false,
|
|
109
|
+
scroll = true,
|
|
110
|
+
onActionData,
|
|
111
|
+
onActionError,
|
|
112
|
+
onSubmit,
|
|
113
|
+
children,
|
|
114
|
+
...props
|
|
115
|
+
}: FormProps) {
|
|
116
|
+
assertActionName(
|
|
117
|
+
action
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const router =
|
|
121
|
+
useRouter();
|
|
122
|
+
const normalizedMethod =
|
|
123
|
+
normalizeMethod(
|
|
124
|
+
method
|
|
125
|
+
);
|
|
126
|
+
const [pending, setPending] =
|
|
127
|
+
useState(
|
|
128
|
+
false
|
|
129
|
+
);
|
|
130
|
+
const [error, setError] =
|
|
131
|
+
useState<Error | null>(
|
|
132
|
+
null
|
|
133
|
+
);
|
|
134
|
+
const [actionData, setActionData] =
|
|
135
|
+
useState<unknown>(
|
|
136
|
+
undefined
|
|
137
|
+
);
|
|
138
|
+
const [hasActionData, setHasActionData] =
|
|
139
|
+
useState(
|
|
140
|
+
false
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const runtimeState =
|
|
144
|
+
useMemo<FormRuntimeState>(
|
|
145
|
+
() => ({
|
|
146
|
+
status: {
|
|
147
|
+
pending,
|
|
148
|
+
action,
|
|
149
|
+
method:
|
|
150
|
+
normalizedMethod,
|
|
151
|
+
error,
|
|
152
|
+
},
|
|
153
|
+
actionData,
|
|
154
|
+
hasActionData,
|
|
155
|
+
}),
|
|
156
|
+
[
|
|
157
|
+
pending,
|
|
158
|
+
action,
|
|
159
|
+
normalizedMethod,
|
|
160
|
+
error,
|
|
161
|
+
actionData,
|
|
162
|
+
hasActionData,
|
|
163
|
+
]
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
async function handleSubmit(
|
|
167
|
+
event: FormSubmitEvent
|
|
168
|
+
) {
|
|
169
|
+
onSubmit?.(
|
|
170
|
+
event
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
event.defaultPrevented ||
|
|
175
|
+
typeof window ===
|
|
176
|
+
"undefined"
|
|
177
|
+
) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
|
|
183
|
+
const form =
|
|
184
|
+
event.currentTarget;
|
|
185
|
+
const data =
|
|
186
|
+
new FormData(
|
|
187
|
+
form
|
|
188
|
+
);
|
|
189
|
+
const targetPath =
|
|
190
|
+
`${window.location.pathname}${window.location.search}`;
|
|
191
|
+
const endpoint =
|
|
192
|
+
createActionEndpoint(
|
|
193
|
+
action,
|
|
194
|
+
normalizedMethod,
|
|
195
|
+
targetPath
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
setPending(
|
|
199
|
+
true
|
|
200
|
+
);
|
|
201
|
+
setError(
|
|
202
|
+
null
|
|
203
|
+
);
|
|
204
|
+
setHasActionData(
|
|
205
|
+
false
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const response =
|
|
210
|
+
await fetch(
|
|
211
|
+
endpoint,
|
|
212
|
+
{
|
|
213
|
+
method:
|
|
214
|
+
"POST",
|
|
215
|
+
credentials:
|
|
216
|
+
"same-origin",
|
|
217
|
+
redirect:
|
|
218
|
+
"manual",
|
|
219
|
+
headers: {
|
|
220
|
+
Accept:
|
|
221
|
+
"application/json",
|
|
222
|
+
"X-BCP-Action":
|
|
223
|
+
"1",
|
|
224
|
+
"X-BCP-Action-Target":
|
|
225
|
+
targetPath,
|
|
226
|
+
},
|
|
227
|
+
body:
|
|
228
|
+
data,
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
response.status ===
|
|
234
|
+
204
|
|
235
|
+
) {
|
|
236
|
+
setActionData(
|
|
237
|
+
null
|
|
238
|
+
);
|
|
239
|
+
setHasActionData(
|
|
240
|
+
true
|
|
241
|
+
);
|
|
242
|
+
onActionData?.(
|
|
243
|
+
null
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (refresh) {
|
|
247
|
+
await router.refresh();
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const payload =
|
|
253
|
+
await readActionPayload(
|
|
254
|
+
response
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (
|
|
258
|
+
payload.kind ===
|
|
259
|
+
"redirect"
|
|
260
|
+
) {
|
|
261
|
+
await navigate(
|
|
262
|
+
payload.redirect.location,
|
|
263
|
+
{
|
|
264
|
+
replace,
|
|
265
|
+
scroll,
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (
|
|
272
|
+
payload.kind ===
|
|
273
|
+
"error"
|
|
274
|
+
) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
payload.error.message
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
setActionData(
|
|
281
|
+
payload.data
|
|
282
|
+
);
|
|
283
|
+
setHasActionData(
|
|
284
|
+
true
|
|
285
|
+
);
|
|
286
|
+
onActionData?.(
|
|
287
|
+
payload.data
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (refresh) {
|
|
291
|
+
await router.refresh();
|
|
292
|
+
}
|
|
293
|
+
} catch (caught) {
|
|
294
|
+
const nextError =
|
|
295
|
+
caught instanceof Error
|
|
296
|
+
? caught
|
|
297
|
+
: new Error(
|
|
298
|
+
String(
|
|
299
|
+
caught
|
|
300
|
+
)
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
setError(
|
|
304
|
+
nextError
|
|
305
|
+
);
|
|
306
|
+
onActionError?.(
|
|
307
|
+
nextError
|
|
308
|
+
);
|
|
309
|
+
} finally {
|
|
310
|
+
setPending(
|
|
311
|
+
false
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const progressiveAction =
|
|
317
|
+
createActionEndpoint(
|
|
318
|
+
action,
|
|
319
|
+
normalizedMethod,
|
|
320
|
+
null
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<FormRuntimeContext.Provider
|
|
325
|
+
value={runtimeState}
|
|
326
|
+
>
|
|
327
|
+
<form
|
|
328
|
+
{...props}
|
|
329
|
+
action={progressiveAction}
|
|
330
|
+
method="post"
|
|
331
|
+
onSubmit={handleSubmit}
|
|
332
|
+
>
|
|
333
|
+
{children}
|
|
334
|
+
</form>
|
|
335
|
+
</FormRuntimeContext.Provider>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function useFormStatus(): FormStatus {
|
|
340
|
+
return useContext(
|
|
341
|
+
FormRuntimeContext
|
|
342
|
+
).status;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function useActionData<
|
|
346
|
+
T = unknown
|
|
347
|
+
>(): T | undefined {
|
|
348
|
+
const state =
|
|
349
|
+
useContext(
|
|
350
|
+
FormRuntimeContext
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
return state.hasActionData
|
|
354
|
+
? state.actionData as T
|
|
355
|
+
: undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function useActionError(): Error | null {
|
|
359
|
+
return useContext(
|
|
360
|
+
FormRuntimeContext
|
|
361
|
+
).status.error;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function createActionEndpoint(
|
|
365
|
+
action: string,
|
|
366
|
+
method: FormActionMethod,
|
|
367
|
+
targetPath: string | null
|
|
368
|
+
): string {
|
|
369
|
+
const params =
|
|
370
|
+
new URLSearchParams({
|
|
371
|
+
name:
|
|
372
|
+
action,
|
|
373
|
+
method:
|
|
374
|
+
method.toUpperCase(),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (targetPath) {
|
|
378
|
+
params.set(
|
|
379
|
+
"url",
|
|
380
|
+
targetPath
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return `/_bcp/action?${params.toString()}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function readActionPayload(
|
|
388
|
+
response: Response
|
|
389
|
+
): Promise<ActionPayload> {
|
|
390
|
+
const contentType =
|
|
391
|
+
response.headers.get(
|
|
392
|
+
"content-type"
|
|
393
|
+
) ??
|
|
394
|
+
"";
|
|
395
|
+
|
|
396
|
+
if (
|
|
397
|
+
!/^application\/json\b/i.test(
|
|
398
|
+
contentType
|
|
399
|
+
)
|
|
400
|
+
) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`BCP Framework: form action returned unsupported HTTP ${response.status} ${contentType || "response"}. Return serializable data or redirect().`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let value:
|
|
407
|
+
unknown;
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
value =
|
|
411
|
+
await response.json();
|
|
412
|
+
} catch {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"BCP Framework: form action returned invalid JSON."
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (
|
|
419
|
+
!isActionPayload(
|
|
420
|
+
value
|
|
421
|
+
)
|
|
422
|
+
) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
"BCP Framework: form action returned an invalid action payload."
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isActionPayload(
|
|
432
|
+
value: unknown
|
|
433
|
+
): value is ActionPayload {
|
|
434
|
+
if (
|
|
435
|
+
value === null ||
|
|
436
|
+
typeof value !==
|
|
437
|
+
"object"
|
|
438
|
+
) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const record =
|
|
443
|
+
value as Record<
|
|
444
|
+
string,
|
|
445
|
+
unknown
|
|
446
|
+
>;
|
|
447
|
+
|
|
448
|
+
if (
|
|
449
|
+
record.kind ===
|
|
450
|
+
"data"
|
|
451
|
+
) {
|
|
452
|
+
return Object.prototype
|
|
453
|
+
.hasOwnProperty.call(
|
|
454
|
+
record,
|
|
455
|
+
"data"
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
record.kind ===
|
|
461
|
+
"redirect"
|
|
462
|
+
) {
|
|
463
|
+
const redirect =
|
|
464
|
+
record.redirect;
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
redirect !== null &&
|
|
468
|
+
typeof redirect ===
|
|
469
|
+
"object" &&
|
|
470
|
+
typeof (
|
|
471
|
+
redirect as Record<
|
|
472
|
+
string,
|
|
473
|
+
unknown
|
|
474
|
+
>
|
|
475
|
+
).location ===
|
|
476
|
+
"string" &&
|
|
477
|
+
typeof (
|
|
478
|
+
redirect as Record<
|
|
479
|
+
string,
|
|
480
|
+
unknown
|
|
481
|
+
>
|
|
482
|
+
).status ===
|
|
483
|
+
"number"
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (
|
|
488
|
+
record.kind ===
|
|
489
|
+
"error"
|
|
490
|
+
) {
|
|
491
|
+
const error =
|
|
492
|
+
record.error;
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
error !== null &&
|
|
496
|
+
typeof error ===
|
|
497
|
+
"object" &&
|
|
498
|
+
typeof (
|
|
499
|
+
error as Record<
|
|
500
|
+
string,
|
|
501
|
+
unknown
|
|
502
|
+
>
|
|
503
|
+
).message ===
|
|
504
|
+
"string"
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function normalizeMethod(
|
|
512
|
+
method: FormActionMethod
|
|
513
|
+
): FormActionMethod {
|
|
514
|
+
const value =
|
|
515
|
+
method.toLowerCase() as
|
|
516
|
+
FormActionMethod;
|
|
517
|
+
|
|
518
|
+
if (
|
|
519
|
+
value !== "post" &&
|
|
520
|
+
value !== "put" &&
|
|
521
|
+
value !== "patch" &&
|
|
522
|
+
value !== "delete"
|
|
523
|
+
) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`BCP Framework: unsupported <Form> method "${String(method)}".`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return value;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function assertActionName(
|
|
533
|
+
action: string
|
|
534
|
+
): void {
|
|
535
|
+
if (
|
|
536
|
+
!ACTION_NAME_PATTERN.test(
|
|
537
|
+
action
|
|
538
|
+
) ||
|
|
539
|
+
action === "default" ||
|
|
540
|
+
action === "__proto__" ||
|
|
541
|
+
action === "prototype" ||
|
|
542
|
+
action === "constructor"
|
|
543
|
+
) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
`BCP Framework: invalid <Form> action name "${action}".`
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -12,12 +12,23 @@ export {
|
|
|
12
12
|
type LinkProps,
|
|
13
13
|
} from "./link.js";
|
|
14
14
|
|
|
15
|
+
export {
|
|
16
|
+
Form,
|
|
17
|
+
useActionData,
|
|
18
|
+
useActionError,
|
|
19
|
+
useFormStatus,
|
|
20
|
+
type FormActionMethod,
|
|
21
|
+
type FormProps,
|
|
22
|
+
type FormStatus,
|
|
23
|
+
} from "./form.js";
|
|
24
|
+
|
|
15
25
|
export {
|
|
16
26
|
useNavigation,
|
|
17
27
|
type NavigationState,
|
|
18
28
|
} from "./navigation-state.js";
|
|
19
29
|
|
|
20
30
|
export {
|
|
31
|
+
useGuardData,
|
|
21
32
|
useLoaderData,
|
|
22
33
|
} from "./loader-data.js";
|
|
23
34
|
|