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/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
- handler: () => buildProject(),
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
  }
@@ -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
- latestRouteFragments.set(handoffKey, artifact)
262
+ if (generation === fragmentGeneration) {
263
+ latestRouteFragments.set(handoffKey, artifact)
264
+ }
265
+
254
266
  return artifact
255
267
  })
256
268
  }
@@ -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
 
package/src/fragment.tsx CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  export {
40
40
  assertRouteFragmentArtifact,
41
41
  getRouteFragment,
42
+ invalidateRouteFragments,
42
43
  isRouteFragmentArtifact,
43
44
  loadRouteFragment,
44
45
  prefetchRouteFragment,