flamefront 0.1.0 → 0.1.2
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/LICENSE.md +2 -91
- package/README.md +22 -8
- package/package.json +19 -21
- package/src/action-client.ts +187 -0
- package/src/action-transform.ts +258 -0
- package/src/action.ts +497 -0
- package/src/cli.ts +8 -2
- package/src/fetch.ts +38 -1
- package/src/fragment-client.ts +13 -1
- package/src/fragment-protocol.ts +2 -0
- package/src/fragment.tsx +1 -0
- package/src/index.ts +38 -0
- package/src/lifecycle.ts +397 -83
- package/src/octane.tsx +33 -3
- package/src/prerender-artifacts.ts +185 -0
- package/src/prerender.ts +376 -0
- package/src/remix-route-data.ts +6 -0
- package/src/route-data-client.ts +26 -0
- package/src/server.ts +108 -2
- package/src/srvx.ts +5 -0
- package/src/static-fragment-artifacts.ts +14 -7
- package/src/virtual-remix-routes.d.ts +1 -0
- package/src/vite-options.ts +29 -0
- package/src/vite.ts +130 -12
package/src/action.ts
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import * as devalue from "devalue"
|
|
2
|
+
|
|
3
|
+
/** The structural value returned by the router's `data()` helper. */
|
|
4
|
+
export interface ActionDataWithResponseInit<Data = unknown> {
|
|
5
|
+
readonly type: "DataWithResponseInit"
|
|
6
|
+
readonly data: Data
|
|
7
|
+
readonly init: ResponseInit | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The Standard Schema contract accepted by `action()`. */
|
|
11
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
12
|
+
readonly "~standard": {
|
|
13
|
+
readonly version: 1
|
|
14
|
+
readonly vendor: string
|
|
15
|
+
readonly validate: (
|
|
16
|
+
value: unknown,
|
|
17
|
+
) =>
|
|
18
|
+
| { readonly value: Output }
|
|
19
|
+
| { readonly issues: readonly StandardSchemaIssue[] }
|
|
20
|
+
| Promise<
|
|
21
|
+
| { readonly value: Output }
|
|
22
|
+
| { readonly issues: readonly StandardSchemaIssue[] }
|
|
23
|
+
>
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StandardSchemaIssue {
|
|
28
|
+
readonly message: string
|
|
29
|
+
readonly path?: readonly (string | number | symbol)[]
|
|
30
|
+
readonly [key: string]: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StandardSchema = StandardSchemaV1<unknown, unknown>
|
|
34
|
+
|
|
35
|
+
type SchemaInput<Schema> =
|
|
36
|
+
Schema extends StandardSchemaV1<infer Input, unknown> ? Input : unknown
|
|
37
|
+
|
|
38
|
+
type SchemaOutput<Schema> =
|
|
39
|
+
Schema extends StandardSchemaV1<unknown, infer Output> ? Output : unknown
|
|
40
|
+
|
|
41
|
+
/** The input tuple inferred from an action's validator tuple. */
|
|
42
|
+
export type ActionInput<Schemas extends readonly StandardSchema[]> = {
|
|
43
|
+
-readonly [Index in keyof Schemas]: SchemaInput<Schemas[Index]>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The validated output tuple inferred from an action's validator tuple. */
|
|
47
|
+
export type ActionOutput<Schemas extends readonly StandardSchema[]> = {
|
|
48
|
+
-readonly [Index in keyof Schemas]: SchemaOutput<Schemas[Index]>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ActionValidationError extends Error {
|
|
52
|
+
readonly issues: readonly StandardSchemaIssue[]
|
|
53
|
+
readonly status: 400
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ActionFunction<
|
|
57
|
+
Args extends readonly unknown[] = readonly unknown[],
|
|
58
|
+
Result = unknown,
|
|
59
|
+
> {
|
|
60
|
+
(...args: Args): Promise<Result>
|
|
61
|
+
readonly actionId?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isDataWithResponseInit(
|
|
65
|
+
value: unknown,
|
|
66
|
+
): value is ActionDataWithResponseInit<unknown> {
|
|
67
|
+
return Boolean(
|
|
68
|
+
value &&
|
|
69
|
+
typeof value === "object" &&
|
|
70
|
+
(value as Partial<ActionDataWithResponseInit>).type ===
|
|
71
|
+
"DataWithResponseInit" &&
|
|
72
|
+
"data" in value &&
|
|
73
|
+
"init" in value,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ActionRecord {
|
|
78
|
+
readonly invoke: (args: readonly unknown[]) => Promise<unknown>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const actionRegistryKey = Symbol.for("flamefront:action-registry")
|
|
82
|
+
const actionRegistry = (() => {
|
|
83
|
+
const globalValue = globalThis as typeof globalThis & {
|
|
84
|
+
[actionRegistryKey]?: Map<string, ActionRecord>
|
|
85
|
+
}
|
|
86
|
+
const existing = globalValue[actionRegistryKey]
|
|
87
|
+
|
|
88
|
+
if (existing) {
|
|
89
|
+
return existing
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const created = new Map<string, ActionRecord>()
|
|
93
|
+
|
|
94
|
+
globalValue[actionRegistryKey] = created
|
|
95
|
+
return created
|
|
96
|
+
})()
|
|
97
|
+
|
|
98
|
+
function validationError(
|
|
99
|
+
issues: readonly StandardSchemaIssue[],
|
|
100
|
+
): ActionValidationError {
|
|
101
|
+
const error = new Error("Invalid action arguments") as ActionValidationError
|
|
102
|
+
|
|
103
|
+
Object.defineProperty(error, "name", {
|
|
104
|
+
configurable: true,
|
|
105
|
+
value: "ActionValidationError",
|
|
106
|
+
})
|
|
107
|
+
Object.defineProperty(error, "issues", {
|
|
108
|
+
configurable: false,
|
|
109
|
+
enumerable: true,
|
|
110
|
+
value: issues,
|
|
111
|
+
})
|
|
112
|
+
Object.defineProperty(error, "status", {
|
|
113
|
+
configurable: false,
|
|
114
|
+
enumerable: true,
|
|
115
|
+
value: 400,
|
|
116
|
+
})
|
|
117
|
+
return error
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function schemaIssue(message: string, index?: number): StandardSchemaIssue {
|
|
121
|
+
return {
|
|
122
|
+
message,
|
|
123
|
+
...(index === undefined ? {} : { path: [index] }),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function validateActionArguments(
|
|
128
|
+
schemas: readonly StandardSchema[] | undefined,
|
|
129
|
+
args: readonly unknown[],
|
|
130
|
+
): Promise<readonly unknown[]> {
|
|
131
|
+
if (!schemas) {
|
|
132
|
+
return args
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const issues: StandardSchemaIssue[] = []
|
|
136
|
+
|
|
137
|
+
if (args.length !== schemas.length) {
|
|
138
|
+
issues.push(
|
|
139
|
+
schemaIssue(
|
|
140
|
+
`Expected ${schemas.length} action argument${schemas.length === 1 ? "" : "s"}, received ${args.length}.`,
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const output = [] as unknown[]
|
|
146
|
+
|
|
147
|
+
for (let index = 0; index < schemas.length; index += 1) {
|
|
148
|
+
const schema = schemas[index]
|
|
149
|
+
|
|
150
|
+
if (!schema || typeof schema["~standard"]?.validate !== "function") {
|
|
151
|
+
throw new TypeError(
|
|
152
|
+
`flamefront action validator at index ${index} does not implement Standard Schema.`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = await schema["~standard"].validate(args[index])
|
|
157
|
+
|
|
158
|
+
if ("issues" in result && result.issues) {
|
|
159
|
+
issues.push(
|
|
160
|
+
...result.issues.map((issue) => ({
|
|
161
|
+
...issue,
|
|
162
|
+
path:
|
|
163
|
+
issue.path && issue.path.length > 0
|
|
164
|
+
? [index, ...issue.path]
|
|
165
|
+
: [index],
|
|
166
|
+
})),
|
|
167
|
+
)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if ("value" in result) {
|
|
172
|
+
output[index] = result.value
|
|
173
|
+
} else {
|
|
174
|
+
issues.push(
|
|
175
|
+
schemaIssue("Validator returned neither a value nor issues.", index),
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (issues.length > 0) {
|
|
181
|
+
throw validationError(issues)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return output
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function actionArguments(
|
|
188
|
+
schemasOrHandler:
|
|
189
|
+
readonly StandardSchema[] | ((...args: readonly unknown[]) => unknown),
|
|
190
|
+
maybeHandler?: (...args: readonly unknown[]) => unknown,
|
|
191
|
+
): {
|
|
192
|
+
readonly schemas: readonly StandardSchema[] | undefined
|
|
193
|
+
readonly handler: (...args: readonly unknown[]) => unknown
|
|
194
|
+
} {
|
|
195
|
+
if (typeof schemasOrHandler === "function") {
|
|
196
|
+
return { schemas: undefined, handler: schemasOrHandler }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (typeof maybeHandler !== "function") {
|
|
200
|
+
throw new TypeError("flamefront action() requires a handler function.")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { schemas: schemasOrHandler, handler: maybeHandler }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createAction(
|
|
207
|
+
id: string | undefined,
|
|
208
|
+
schemasOrHandler:
|
|
209
|
+
readonly StandardSchema[] | ((...args: readonly unknown[]) => unknown),
|
|
210
|
+
maybeHandler?: (...args: readonly unknown[]) => unknown,
|
|
211
|
+
): ActionFunction {
|
|
212
|
+
const { schemas, handler } = actionArguments(schemasOrHandler, maybeHandler)
|
|
213
|
+
const invoke = async (...args: readonly unknown[]): Promise<unknown> => {
|
|
214
|
+
const validated = await validateActionArguments(schemas, args)
|
|
215
|
+
|
|
216
|
+
return handler(...validated)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const actionFunction = Object.assign(invoke, {
|
|
220
|
+
...(id === undefined ? {} : { actionId: id }),
|
|
221
|
+
}) as ActionFunction
|
|
222
|
+
|
|
223
|
+
if (id !== undefined) {
|
|
224
|
+
actionRegistry.set(id, { invoke: (args) => actionFunction(...args) })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return actionFunction
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Declare a callable server action.
|
|
232
|
+
*
|
|
233
|
+
* The optional validator tuple supplies both the caller's input types and the
|
|
234
|
+
* handler's validated output types. Without validators, arguments are
|
|
235
|
+
* `unknown[]` and the handler must narrow them itself.
|
|
236
|
+
*/
|
|
237
|
+
export function action<const Schemas extends readonly StandardSchema[], Result>(
|
|
238
|
+
schemas: Schemas,
|
|
239
|
+
handler: (...args: ActionOutput<Schemas>) => Result | Promise<Result>,
|
|
240
|
+
): ActionFunction<ActionInput<Schemas>, Awaited<Result>>
|
|
241
|
+
export function action<Result>(
|
|
242
|
+
handler: (...args: unknown[]) => Result | Promise<Result>,
|
|
243
|
+
): ActionFunction<readonly unknown[], Awaited<Result>>
|
|
244
|
+
/** @internal Used by the Vite transform to attach a stable action ID. */
|
|
245
|
+
export function action<const Schemas extends readonly StandardSchema[], Result>(
|
|
246
|
+
id: string,
|
|
247
|
+
schemas: Schemas,
|
|
248
|
+
handler: (...args: ActionOutput<Schemas>) => Result | Promise<Result>,
|
|
249
|
+
): ActionFunction<ActionInput<Schemas>, Awaited<Result>>
|
|
250
|
+
/** @internal Used by the Vite transform to attach a stable action ID. */
|
|
251
|
+
export function action<Result>(
|
|
252
|
+
id: string,
|
|
253
|
+
handler: (...args: unknown[]) => Result | Promise<Result>,
|
|
254
|
+
): ActionFunction<readonly unknown[], Awaited<Result>>
|
|
255
|
+
export function action(
|
|
256
|
+
first:
|
|
257
|
+
| string
|
|
258
|
+
| readonly StandardSchema[]
|
|
259
|
+
| ((...args: readonly unknown[]) => unknown),
|
|
260
|
+
second?:
|
|
261
|
+
readonly StandardSchema[] | ((...args: readonly unknown[]) => unknown),
|
|
262
|
+
third?: (...args: readonly unknown[]) => unknown,
|
|
263
|
+
): ActionFunction {
|
|
264
|
+
if (typeof first === "string") {
|
|
265
|
+
return createAction(
|
|
266
|
+
first,
|
|
267
|
+
second as
|
|
268
|
+
readonly StandardSchema[] | ((...args: readonly unknown[]) => unknown),
|
|
269
|
+
third,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return createAction(
|
|
274
|
+
undefined,
|
|
275
|
+
first,
|
|
276
|
+
second as ((...args: readonly unknown[]) => unknown) | undefined,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function getRegisteredAction(
|
|
281
|
+
actionId: string,
|
|
282
|
+
): ActionRecord | undefined {
|
|
283
|
+
return actionRegistry.get(actionId)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface SerializedActionError {
|
|
287
|
+
readonly name: string
|
|
288
|
+
readonly message: string
|
|
289
|
+
readonly issues?: readonly StandardSchemaIssue[]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface ActionEnvelope {
|
|
293
|
+
readonly protocol: "flamefront-action-v1"
|
|
294
|
+
readonly type: "data" | "error"
|
|
295
|
+
readonly value?: unknown
|
|
296
|
+
readonly error?: SerializedActionError
|
|
297
|
+
readonly status: number
|
|
298
|
+
readonly headers: readonly (readonly [string, string])[]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function responseHeaders(
|
|
302
|
+
init: ResponseInit | null | undefined,
|
|
303
|
+
): readonly (readonly [string, string])[] {
|
|
304
|
+
return init?.headers ? [...new Headers(init.headers)] : []
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function responseStatus(init: ResponseInit | null | undefined): number {
|
|
308
|
+
return init?.status ?? 200
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function serializedError(value: unknown): SerializedActionError {
|
|
312
|
+
if (value instanceof Error) {
|
|
313
|
+
const actionError = value as Error & {
|
|
314
|
+
readonly issues?: readonly StandardSchemaIssue[]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
name: value.name,
|
|
319
|
+
message: value.message,
|
|
320
|
+
...(actionError.issues ? { issues: actionError.issues } : {}),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { name: "Error", message: String(value) }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function envelopeResponse(envelope: ActionEnvelope): Response {
|
|
328
|
+
const headers = new Headers(
|
|
329
|
+
envelope.headers.map(([name, value]) => [name, value] as [string, string]),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
headers.set("Content-Type", "application/vnd.flamefront.action+devalue")
|
|
333
|
+
return new Response(devalue.stringify(envelope), {
|
|
334
|
+
status: envelope.status,
|
|
335
|
+
headers,
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Encode an action return value or error as a Fetch response. */
|
|
340
|
+
export function actionResultResponse(value: unknown): Response {
|
|
341
|
+
if (value instanceof Response) {
|
|
342
|
+
return value
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (isDataWithResponseInit(value)) {
|
|
346
|
+
return envelopeResponse({
|
|
347
|
+
protocol: "flamefront-action-v1",
|
|
348
|
+
type: "data",
|
|
349
|
+
value: value.data,
|
|
350
|
+
status: responseStatus(value.init),
|
|
351
|
+
headers: responseHeaders(value.init),
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return envelopeResponse({
|
|
356
|
+
protocol: "flamefront-action-v1",
|
|
357
|
+
type: "data",
|
|
358
|
+
value,
|
|
359
|
+
status: 200,
|
|
360
|
+
headers: [],
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Encode an action error as a Fetch response. */
|
|
365
|
+
export function actionErrorResponse(error: unknown): Response {
|
|
366
|
+
if (error instanceof Response) {
|
|
367
|
+
return error
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (isDataWithResponseInit(error)) {
|
|
371
|
+
return envelopeResponse({
|
|
372
|
+
protocol: "flamefront-action-v1",
|
|
373
|
+
type: "error",
|
|
374
|
+
error: serializedError(error.data),
|
|
375
|
+
value: error.data,
|
|
376
|
+
status: responseStatus(error.init),
|
|
377
|
+
headers: responseHeaders(error.init),
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const status =
|
|
382
|
+
typeof error === "object" && error !== null && "status" in error
|
|
383
|
+
? Number((error as { status?: unknown }).status) || 500
|
|
384
|
+
: 500
|
|
385
|
+
|
|
386
|
+
return envelopeResponse({
|
|
387
|
+
protocol: "flamefront-action-v1",
|
|
388
|
+
type: "error",
|
|
389
|
+
error: serializedError(error),
|
|
390
|
+
status,
|
|
391
|
+
headers: [],
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Execute an explicitly declared action by its generated stable ID. */
|
|
396
|
+
export async function executeRegisteredAction(
|
|
397
|
+
actionId: string,
|
|
398
|
+
args: readonly unknown[],
|
|
399
|
+
): Promise<Response> {
|
|
400
|
+
const record = getRegisteredAction(actionId)
|
|
401
|
+
|
|
402
|
+
if (!record) {
|
|
403
|
+
const error = new Error("Flamefront action was not found.") as Error & {
|
|
404
|
+
readonly status: number
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
Object.defineProperty(error, "status", {
|
|
408
|
+
configurable: true,
|
|
409
|
+
value: 404,
|
|
410
|
+
})
|
|
411
|
+
return actionErrorResponse(error)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
return actionResultResponse(await record.invoke(args))
|
|
416
|
+
} catch (error) {
|
|
417
|
+
return actionErrorResponse(error)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Parse the devalue argument-array body used by callable actions. */
|
|
422
|
+
export async function parseActionArguments(
|
|
423
|
+
request: Request,
|
|
424
|
+
): Promise<readonly unknown[]> {
|
|
425
|
+
let value: unknown
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
value = devalue.parse(await request.text())
|
|
429
|
+
} catch (error) {
|
|
430
|
+
const parseError = new TypeError("Invalid Flamefront action arguments.", {
|
|
431
|
+
cause: error,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
Object.defineProperty(parseError, "status", {
|
|
435
|
+
configurable: true,
|
|
436
|
+
value: 400,
|
|
437
|
+
})
|
|
438
|
+
throw parseError
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!Array.isArray(value)) {
|
|
442
|
+
throw new TypeError("Flamefront action arguments must be an array.")
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return value
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function actionProtocolEnvelope(
|
|
449
|
+
value: unknown,
|
|
450
|
+
): value is ActionEnvelope {
|
|
451
|
+
return Boolean(
|
|
452
|
+
value &&
|
|
453
|
+
typeof value === "object" &&
|
|
454
|
+
(value as Partial<ActionEnvelope>).protocol === "flamefront-action-v1" &&
|
|
455
|
+
((value as Partial<ActionEnvelope>).type === "data" ||
|
|
456
|
+
(value as Partial<ActionEnvelope>).type === "error"),
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function actionResponseInit(
|
|
461
|
+
envelope: Pick<ActionEnvelope, "status" | "headers">,
|
|
462
|
+
): ResponseInit {
|
|
463
|
+
return {
|
|
464
|
+
status: envelope.status,
|
|
465
|
+
headers: new Headers(
|
|
466
|
+
envelope.headers.map(
|
|
467
|
+
([name, value]) => [name, value] as [string, string],
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Check the request metadata used by browsers to enforce same-origin writes. */
|
|
474
|
+
export function isSameOriginActionRequest(request: Request): boolean {
|
|
475
|
+
const url = new URL(request.url)
|
|
476
|
+
const origin = request.headers.get("Origin")
|
|
477
|
+
|
|
478
|
+
if (origin && origin !== url.origin) {
|
|
479
|
+
return false
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (!origin) {
|
|
483
|
+
const referer = request.headers.get("Referer")
|
|
484
|
+
|
|
485
|
+
if (referer) {
|
|
486
|
+
try {
|
|
487
|
+
if (new URL(referer).origin !== url.origin) {
|
|
488
|
+
return false
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
return false
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return request.headers.get("Sec-Fetch-Site") !== "cross-site"
|
|
497
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -64,8 +64,14 @@ const dev = command({
|
|
|
64
64
|
const build = command({
|
|
65
65
|
name: "build",
|
|
66
66
|
description: "Build client and server bundles, then prerender static routes.",
|
|
67
|
-
args: {
|
|
68
|
-
|
|
67
|
+
args: {
|
|
68
|
+
forcePrerender: flag({
|
|
69
|
+
long: "force-prerender",
|
|
70
|
+
description: "Ignore prerender cache entries and refresh them.",
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
handler: ({ forcePrerender }) =>
|
|
74
|
+
buildProject(process.cwd(), { forcePrerender }),
|
|
69
75
|
})
|
|
70
76
|
|
|
71
77
|
const preview = command({
|
package/src/fetch.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
stripFlamefrontProtocolRequest,
|
|
11
11
|
} from "./fragment-protocol.ts"
|
|
12
12
|
import type { RouteFragmentArtifact } from "./fragment-client.ts"
|
|
13
|
+
import { isSameOriginActionRequest } from "./action.ts"
|
|
13
14
|
|
|
14
15
|
export interface TemplateContext<
|
|
15
16
|
Route extends RouteDefinition = RouteDefinition,
|
|
@@ -65,12 +66,13 @@ export type ServerDocuments = Pick<
|
|
|
65
66
|
OctaneDocuments,
|
|
66
67
|
"renderDocument" | "loadRouteData"
|
|
67
68
|
> &
|
|
68
|
-
Partial<Pick<OctaneDocuments, "renderFragment">>
|
|
69
|
+
Partial<Pick<OctaneDocuments, "renderFragment" | "loadAction">>
|
|
69
70
|
|
|
70
71
|
/** Lifecycle operations shared by all Flamefront server adapters. */
|
|
71
72
|
export interface ServerEntryLifecycle {
|
|
72
73
|
readonly renderDocument: OctaneDocuments["renderDocument"]
|
|
73
74
|
readonly loadRouteData: OctaneDocuments["loadRouteData"]
|
|
75
|
+
readonly loadAction?: OctaneDocuments["loadAction"]
|
|
74
76
|
readonly renderFragment?: OctaneDocuments["renderFragment"]
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -133,6 +135,15 @@ export function createFetchServerEntry<
|
|
|
133
135
|
const match = options.app.match(url)
|
|
134
136
|
|
|
135
137
|
try {
|
|
138
|
+
const actionRequest =
|
|
139
|
+
url.searchParams.has("action") ||
|
|
140
|
+
url.searchParams.has("__flamefront_action") ||
|
|
141
|
+
(match !== null && !["GET", "HEAD", "OPTIONS"].includes(request.method))
|
|
142
|
+
|
|
143
|
+
if (actionRequest && !isSameOriginActionRequest(request)) {
|
|
144
|
+
return new Response("Forbidden.", { status: 403 })
|
|
145
|
+
}
|
|
146
|
+
|
|
136
147
|
if (
|
|
137
148
|
url.pathname === options.app.routing.basename &&
|
|
138
149
|
!match &&
|
|
@@ -149,6 +160,29 @@ export function createFetchServerEntry<
|
|
|
149
160
|
})
|
|
150
161
|
}
|
|
151
162
|
|
|
163
|
+
if (
|
|
164
|
+
url.searchParams.has("action") ||
|
|
165
|
+
url.searchParams.has("__flamefront_action")
|
|
166
|
+
) {
|
|
167
|
+
if (!options.documents.loadAction) {
|
|
168
|
+
return new Response("Actions are not configured.", { status: 404 })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return options.documents.loadAction(request)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
|
|
176
|
+
match?.data.render === "static"
|
|
177
|
+
) {
|
|
178
|
+
return options.documents.loadAction
|
|
179
|
+
? options.documents.loadAction(request)
|
|
180
|
+
: new Response(
|
|
181
|
+
"Static routes cannot define actions; submit to a server route instead.",
|
|
182
|
+
{ status: 405 },
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
152
186
|
if (url.pathname === options.app.routing.dataPath) {
|
|
153
187
|
return options.documents.loadRouteData(request)
|
|
154
188
|
}
|
|
@@ -275,6 +309,9 @@ export function createFetchServerEntry<
|
|
|
275
309
|
fetch,
|
|
276
310
|
renderDocument: options.documents.renderDocument,
|
|
277
311
|
loadRouteData: options.documents.loadRouteData,
|
|
312
|
+
...(options.documents.loadAction
|
|
313
|
+
? { loadAction: options.documents.loadAction }
|
|
314
|
+
: {}),
|
|
278
315
|
renderFragment: options.documents.renderFragment,
|
|
279
316
|
}
|
|
280
317
|
}
|
package/src/fragment-client.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function shouldHydrateRouteFragment(
|
|
|
46
46
|
const staticFragmentRequests = new Map<string, Promise<RouteFragmentArtifact>>()
|
|
47
47
|
const serverFragmentRequests = new Map<string, Promise<RouteFragmentArtifact>>()
|
|
48
48
|
const latestRouteFragments = new Map<string, RouteFragmentArtifact>()
|
|
49
|
+
let fragmentGeneration = 0
|
|
49
50
|
|
|
50
51
|
function resolveRouteUrl(input: string | URL): URL {
|
|
51
52
|
const browserOrigin =
|
|
@@ -156,6 +157,13 @@ export function getRouteFragment(
|
|
|
156
157
|
)
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/** Drop browser fragment state after a successful mutation. */
|
|
161
|
+
export function invalidateRouteFragments(): void {
|
|
162
|
+
fragmentGeneration += 1
|
|
163
|
+
serverFragmentRequests.clear()
|
|
164
|
+
latestRouteFragments.clear()
|
|
165
|
+
}
|
|
166
|
+
|
|
159
167
|
function fetchRouteFragment(
|
|
160
168
|
routeUrl: URL,
|
|
161
169
|
basename: string,
|
|
@@ -212,6 +220,7 @@ export function loadRouteFragment(
|
|
|
212
220
|
options: RouteFragmentLoadOptions,
|
|
213
221
|
): Promise<RouteFragmentArtifact> {
|
|
214
222
|
const routeUrl = resolveRouteUrl(url)
|
|
223
|
+
const generation = fragmentGeneration
|
|
215
224
|
const handoffKey = routeFragmentKey(routeUrl, routing.basename ?? "/")
|
|
216
225
|
const requests =
|
|
217
226
|
options.policy === "static"
|
|
@@ -250,7 +259,10 @@ export function loadRouteFragment(
|
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
return abortable(pending, options.signal).then((artifact) => {
|
|
253
|
-
|
|
262
|
+
if (generation === fragmentGeneration) {
|
|
263
|
+
latestRouteFragments.set(handoffKey, artifact)
|
|
264
|
+
}
|
|
265
|
+
|
|
254
266
|
return artifact
|
|
255
267
|
})
|
|
256
268
|
}
|
package/src/fragment-protocol.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const flamefrontFragmentQueryParam = "__flamefront_fragment"
|
|
2
2
|
export const flamefrontShellQueryParam = "__flamefront_shell"
|
|
3
3
|
export const flamefrontFragmentQueryValue = "1"
|
|
4
|
+
export const flamefrontActionQueryParam = "__flamefront_action"
|
|
4
5
|
|
|
5
6
|
/** Remove framework-only query parameters before a URL reaches app code. */
|
|
6
7
|
export function stripFlamefrontProtocolParams(input: string | URL): URL {
|
|
@@ -8,6 +9,7 @@ export function stripFlamefrontProtocolParams(input: string | URL): URL {
|
|
|
8
9
|
|
|
9
10
|
url.searchParams.delete(flamefrontFragmentQueryParam)
|
|
10
11
|
url.searchParams.delete(flamefrontShellQueryParam)
|
|
12
|
+
url.searchParams.delete(flamefrontActionQueryParam)
|
|
11
13
|
return url
|
|
12
14
|
}
|
|
13
15
|
|