@zerotal/flow 1.0.0
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 +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// ── Core shared types for @zerotal/flow ────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The wire-format types that round-trip between server and client: the serialized
|
|
4
|
+
// component snapshot, the inbound `call` frame, and the outbound server frames
|
|
5
|
+
// (`patch`, `flash`, `redirect`, `error`, …).
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A single serialized property: `[value, synthMetadata]`.
|
|
9
|
+
* @category Snapshot
|
|
10
|
+
*/
|
|
11
|
+
export type SnapshotTuple = [data: unknown, meta: Record<string, unknown>];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The "data" map inside a snapshot: `propertyName → [value, meta]`.
|
|
15
|
+
* @category Snapshot
|
|
16
|
+
*/
|
|
17
|
+
export type SnapshotData = Record<string, SnapshotTuple>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Metadata for a child Component component embedded inside a parent's render output.
|
|
21
|
+
* Stored in memo.children (excluded from HMAC signing — same as Livewire).
|
|
22
|
+
* @category Snapshot
|
|
23
|
+
*/
|
|
24
|
+
export interface ChildMemo {
|
|
25
|
+
/** The JSX `key` prop that identifies this slot in the parent's template. */
|
|
26
|
+
key: string;
|
|
27
|
+
/** Stable component ID (data-flow-id on the [data-flow-root] element). */
|
|
28
|
+
id: string;
|
|
29
|
+
/** Child class name — used to route WebSocket actions. */
|
|
30
|
+
name: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Component identity / routing metadata embedded in the snapshot.
|
|
35
|
+
* @category Snapshot
|
|
36
|
+
*/
|
|
37
|
+
export interface SnapshotMemo {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
path: string;
|
|
41
|
+
children: string[];
|
|
42
|
+
/** @on listener map: eventName → methodName. Excluded from HMAC (server-derived). */
|
|
43
|
+
listeners?: Record<string, string> | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Named slot HTML passed down by the parent's template (`slotName → html`; the
|
|
46
|
+
* default slot is keyed `"default"`). Rendered in the parent's scope, then carried
|
|
47
|
+
* in the child's snapshot so the child can re-render its own actions with the slot
|
|
48
|
+
* content intact. Signed (server-generated, never trusted from the client).
|
|
49
|
+
*/
|
|
50
|
+
slots?: Record<string, string> | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* `@presence` props → their resolved channel, so the client subscribes to the right
|
|
53
|
+
* presence channel(s) and refreshes the member list on join/leave. Signed (the channel
|
|
54
|
+
* is server-resolved from the component, not trusted from the client).
|
|
55
|
+
*/
|
|
56
|
+
presence?: Array<{ prop: string; channel: string }> | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* `@shared` props → their resolved channel, so the client subscribes and dispatches
|
|
59
|
+
* `$shared` (re-read the room store + re-render) when the channel signals a change.
|
|
60
|
+
* Signed (server-resolved from the component, not trusted from the client).
|
|
61
|
+
*/
|
|
62
|
+
shared?: Array<{ prop: string; channel: string }> | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* The authenticated user this snapshot was issued to, or `undefined` when it was issued
|
|
65
|
+
* to an anonymous request. Signed, and checked on every hydrate.
|
|
66
|
+
*
|
|
67
|
+
* The HMAC proves a snapshot has not been *tampered with*; it says nothing about who it
|
|
68
|
+
* belongs to. Without this binding, `@locked accountId = 42` — documented as tamper-proof
|
|
69
|
+
* server-authoritative state — could be replayed verbatim by any other authenticated
|
|
70
|
+
* user who obtained a snapshot signed for someone else, with the framework's own
|
|
71
|
+
* signature vouching for it. Middleware still re-runs, so auth is re-checked; the data is
|
|
72
|
+
* what the snapshot says.
|
|
73
|
+
*/
|
|
74
|
+
sub?: string | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Issue time, epoch seconds. Signed, and checked on every hydrate against
|
|
77
|
+
* `SNAPSHOT_MAX_AGE_SECONDS` so a captured snapshot is not valid indefinitely.
|
|
78
|
+
*/
|
|
79
|
+
iat?: number | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The full snapshot that round-trips between server and client.
|
|
84
|
+
* @category Snapshot
|
|
85
|
+
*/
|
|
86
|
+
export interface Snapshot {
|
|
87
|
+
data: SnapshotData;
|
|
88
|
+
memo: SnapshotMemo;
|
|
89
|
+
checksum: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Outbound WS frame sent by the client to the server.
|
|
94
|
+
* @category Frames
|
|
95
|
+
*/
|
|
96
|
+
export interface CallFrame {
|
|
97
|
+
type: "call";
|
|
98
|
+
component: string;
|
|
99
|
+
method: string;
|
|
100
|
+
args: unknown[];
|
|
101
|
+
updates: Record<string, unknown>;
|
|
102
|
+
snapshot: Snapshot;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Patch frame sent by the server to the client after a successful action.
|
|
107
|
+
*
|
|
108
|
+
* The snapshot travels as a **delta** by default: only changed fields (`dataDelta`)
|
|
109
|
+
* and removed keys (`dataRemoved`) are sent, alongside the new `memo` and full-snapshot
|
|
110
|
+
* `checksum`. The client rebuilds the full snapshot from its prior copy + the delta (an
|
|
111
|
+
* exact reconstruction, since the server diffed against the client's own snapshot). The
|
|
112
|
+
* legacy `snapshot` field remains as a full fallback. `html` is omitted when the
|
|
113
|
+
* re-render is byte-identical to the last patch on this connection (HTML suppression).
|
|
114
|
+
* @category Frames
|
|
115
|
+
*/
|
|
116
|
+
export interface PatchFrame {
|
|
117
|
+
type: "patch";
|
|
118
|
+
component: string;
|
|
119
|
+
html?: string;
|
|
120
|
+
// Delta encoding (default on WebSocket patches):
|
|
121
|
+
memo?: SnapshotMemo;
|
|
122
|
+
checksum?: string;
|
|
123
|
+
dataDelta?: SnapshotData;
|
|
124
|
+
dataRemoved?: string[];
|
|
125
|
+
// Full snapshot — legacy/fallback path (e.g. when there is no client base to diff against).
|
|
126
|
+
snapshot?: Snapshot;
|
|
127
|
+
scripts?: string[];
|
|
128
|
+
errors?: Record<string, string[]>;
|
|
129
|
+
title?: string;
|
|
130
|
+
/** True when the action (or a rejected client write) failed — drives `showOnError`. */
|
|
131
|
+
actionError?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* True for a mid-`@task` streaming patch: the client applies the DOM/snapshot but keeps the
|
|
134
|
+
* triggering action's loading state on and does not release the per-component send queue —
|
|
135
|
+
* only the final (non-partial) patch does. Absent on ordinary patches.
|
|
136
|
+
*/
|
|
137
|
+
partial?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where a toast is anchored on screen.
|
|
142
|
+
* @category Flash
|
|
143
|
+
*/
|
|
144
|
+
export type FlashPosition =
|
|
145
|
+
"top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Button fill style.
|
|
149
|
+
* @category Flash
|
|
150
|
+
*/
|
|
151
|
+
export type FlashActionVariant = "solid" | "soft" | "ghost";
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Constrained, inline styling for a toast action button. Kept to a small,
|
|
155
|
+
* purge-proof set (toasts are built at runtime, so arbitrary Tailwind classes
|
|
156
|
+
* would be stripped) — pick a `color`, a fill `variant`, and/or `uppercase`.
|
|
157
|
+
* @category Flash
|
|
158
|
+
*/
|
|
159
|
+
export interface FlashActionStyle {
|
|
160
|
+
/** Accent: a level name (`success`/`error`/`warning`/`info`), `neutral`, or any CSS color. */
|
|
161
|
+
color?: string;
|
|
162
|
+
/** Fill style — `soft` (default), `solid`, or `ghost`. */
|
|
163
|
+
variant?: FlashActionVariant;
|
|
164
|
+
/** Render the label uppercase (adds letter-spacing). */
|
|
165
|
+
uppercase?: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An interactive button inside a toast. `method` names a `@expose` action on the
|
|
170
|
+
* component that emitted the flash; clicking the button invokes it over the WS
|
|
171
|
+
* bridge (with optional `args`), then dismisses the toast.
|
|
172
|
+
* @category Flash
|
|
173
|
+
*/
|
|
174
|
+
export interface FlashAction extends FlashActionStyle {
|
|
175
|
+
label: string;
|
|
176
|
+
method: string;
|
|
177
|
+
args?: unknown[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A `@expose` method (+ args) invoked when the toast is dismissed.
|
|
182
|
+
* @category Flash
|
|
183
|
+
*/
|
|
184
|
+
export interface FlashCallback {
|
|
185
|
+
method: string;
|
|
186
|
+
args?: unknown[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A flash notification payload (server → client). `message`/`level` are the core;
|
|
191
|
+
* everything else is an optional per-toast override of the `<Flash>` defaults.
|
|
192
|
+
* @category Flash
|
|
193
|
+
*/
|
|
194
|
+
export interface FlashMessage {
|
|
195
|
+
message: string;
|
|
196
|
+
level: "success" | "error" | "warning" | "info";
|
|
197
|
+
/** Bold header rendered above the message. */
|
|
198
|
+
title?: string;
|
|
199
|
+
/** Override the `<Flash>` container position for this toast. */
|
|
200
|
+
position?: FlashPosition;
|
|
201
|
+
/** Auto-dismiss after N ms. `0` (or negative) keeps it until dismissed. */
|
|
202
|
+
duration?: number;
|
|
203
|
+
/** Render a close (×) button. Forced on when the toast doesn't auto-dismiss. */
|
|
204
|
+
dismissible?: boolean;
|
|
205
|
+
/** Custom icon (emoji/text), or `false` to hide the status icon. */
|
|
206
|
+
icon?: string | false;
|
|
207
|
+
/** Show a countdown progress bar (only meaningful while auto-dismissing). */
|
|
208
|
+
progressBar?: boolean;
|
|
209
|
+
/** Action buttons (e.g. "Undo") that each invoke a `@expose` method on click. */
|
|
210
|
+
actions?: FlashAction[];
|
|
211
|
+
/** A `@expose` method invoked when the toast is dismissed (any path). */
|
|
212
|
+
onClose?: FlashCallback;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Flash notification frame.
|
|
217
|
+
* @category Frames
|
|
218
|
+
*/
|
|
219
|
+
export interface FlashFrame extends FlashMessage {
|
|
220
|
+
type: "flash";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Programmatic redirect frame.
|
|
225
|
+
* @category Frames
|
|
226
|
+
*/
|
|
227
|
+
export interface RedirectFrame {
|
|
228
|
+
type: "redirect";
|
|
229
|
+
url: string;
|
|
230
|
+
sessionToken?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Error frame (action threw, onError ran).
|
|
235
|
+
* @category Frames
|
|
236
|
+
*/
|
|
237
|
+
export interface ErrorFrame {
|
|
238
|
+
type: "error";
|
|
239
|
+
component: string;
|
|
240
|
+
message: string;
|
|
241
|
+
/**
|
|
242
|
+
* Dev-only error detail — the server attaches these only under the `serve --dev` worker so the
|
|
243
|
+
* client can render a full-screen error overlay. Never sent in production (no stack leak).
|
|
244
|
+
*/
|
|
245
|
+
name?: string;
|
|
246
|
+
stack?: string;
|
|
247
|
+
action?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Stream frame.
|
|
252
|
+
* @category Frames
|
|
253
|
+
*/
|
|
254
|
+
export interface StreamFrame {
|
|
255
|
+
type: "stream";
|
|
256
|
+
ref: string;
|
|
257
|
+
content: string;
|
|
258
|
+
replace: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Cross-component event frame.
|
|
263
|
+
* @category Frames
|
|
264
|
+
*/
|
|
265
|
+
export interface EventFrame {
|
|
266
|
+
type: "event";
|
|
267
|
+
name: string;
|
|
268
|
+
data: Record<string, unknown>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* File download frame.
|
|
273
|
+
* @category Frames
|
|
274
|
+
*/
|
|
275
|
+
export interface DownloadFrame {
|
|
276
|
+
type: "download";
|
|
277
|
+
filename: string;
|
|
278
|
+
content: string;
|
|
279
|
+
mime: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Sent after WS actions that mutated @session props; the client fetches the relay endpoint.
|
|
284
|
+
* @category Frames
|
|
285
|
+
*/
|
|
286
|
+
export interface SessionFrame {
|
|
287
|
+
type: "session";
|
|
288
|
+
token: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Sent when the WS connection opens; `dev` enables client-side fast refresh on reconnect.
|
|
293
|
+
* @category Frames
|
|
294
|
+
*/
|
|
295
|
+
export interface ReadyFrame {
|
|
296
|
+
type: "ready";
|
|
297
|
+
dev?: boolean;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Union of every frame the server can push to the client over the WebSocket bridge.
|
|
302
|
+
* @category Frames
|
|
303
|
+
*/
|
|
304
|
+
export type ServerFrame =
|
|
305
|
+
| PatchFrame
|
|
306
|
+
| FlashFrame
|
|
307
|
+
| RedirectFrame
|
|
308
|
+
| ErrorFrame
|
|
309
|
+
| StreamFrame
|
|
310
|
+
| EventFrame
|
|
311
|
+
| DownloadFrame
|
|
312
|
+
| SessionFrame
|
|
313
|
+
| ReadyFrame;
|
|
314
|
+
|
|
315
|
+
export type FlashLevel = FlashMessage["level"];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ── FileUploads mixin ─────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The TypeScript equivalent of Livewire's `WithFileUploads` trait. File uploads work
|
|
4
|
+
// like any other input: a `<FileUpload>` / file `<input flow:model>` POSTs the bytes to
|
|
5
|
+
// `/__flow/upload` (over HTTP), shows live progress, then `$set`s a signed reference
|
|
6
|
+
// that the base Component resolves into a `TemporaryUploadedFile` (signature verified).
|
|
7
|
+
// This mixin adds the ergonomic server-side actions for managing those pending uploads —
|
|
8
|
+
// chiefly removing a chosen file before it's stored — without bloating the base class.
|
|
9
|
+
//
|
|
10
|
+
// class Avatar extends ComponentWith(FileUploads) {
|
|
11
|
+
// @expose photo: TemporaryUploadedFile | null = null;
|
|
12
|
+
// @expose async save() {
|
|
13
|
+
// this.path = await this.photo?.store("avatars"); // persist permanently
|
|
14
|
+
// this.photo = null;
|
|
15
|
+
// }
|
|
16
|
+
// async render() {
|
|
17
|
+
// // <FileUpload bind={this.photo} accept="image/*" />
|
|
18
|
+
// // + a remove button → onClick={() => this.removeUpload("photo")}
|
|
19
|
+
// }
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Multiple files: bind the property to an array and pass `multiple` to <FileUpload>;
|
|
23
|
+
// `removeUpload(prop, index)` drops one entry, or all when `index` is omitted.
|
|
24
|
+
|
|
25
|
+
import { Component } from "../Component.ts";
|
|
26
|
+
import { expose } from "../decorators.ts";
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
type AbstractComponentCtor = abstract new (...args: any[]) => Component;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Component mixin that adds server-side file-upload management — flow's
|
|
33
|
+
* equivalent of Livewire's `WithFileUploads` trait.
|
|
34
|
+
*
|
|
35
|
+
* @remarks
|
|
36
|
+
* Uploading itself needs no mixin: a `<FileUpload>` / file `<input flow:model>`
|
|
37
|
+
* POSTs bytes to `/__flow/upload` (over HTTP, with live progress) and then
|
|
38
|
+
* `$set`s a signed reference that the base Component resolves into a
|
|
39
|
+
* {@link TemporaryUploadedFile}. This mixin adds the ergonomic action for managing
|
|
40
|
+
* those pending uploads — chiefly {@link WithUploads.removeUpload | removeUpload},
|
|
41
|
+
* for clearing a chosen file before it's stored — without bloating the base class.
|
|
42
|
+
* Bind a single property to accept one file, or an array (with `multiple` on
|
|
43
|
+
* `<FileUpload>`) to accept several.
|
|
44
|
+
*
|
|
45
|
+
* @typeParam TBase - The (abstract) Component constructor being extended.
|
|
46
|
+
* @param Base - The component class to mix into.
|
|
47
|
+
* @returns A subclass of `Base` with upload-management actions.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* class Avatar extends ComponentWith(FileUploads) {
|
|
52
|
+
* @expose photo: TemporaryUploadedFile | null = null;
|
|
53
|
+
* @expose path = "";
|
|
54
|
+
*
|
|
55
|
+
* @expose async save() {
|
|
56
|
+
* this.path = (await this.photo?.store("avatars")) ?? this.path;
|
|
57
|
+
* this.photo = null;
|
|
58
|
+
* }
|
|
59
|
+
*
|
|
60
|
+
* async render() {
|
|
61
|
+
* return (
|
|
62
|
+
* <div>
|
|
63
|
+
* <FileUpload bind={this.photo} accept="image/*" />
|
|
64
|
+
* <button onClick={() => this.removeUpload("photo")}>Remove</button>
|
|
65
|
+
* <button onClick={this.save}>Save</button>
|
|
66
|
+
* </div>
|
|
67
|
+
* );
|
|
68
|
+
* }
|
|
69
|
+
* }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function FileUploads<TBase extends AbstractComponentCtor>(Base: TBase) {
|
|
73
|
+
abstract class WithUploads extends Base {
|
|
74
|
+
/**
|
|
75
|
+
* Remove a pending upload (Livewire's `removeUpload`). For a single-file property it
|
|
76
|
+
* clears it (null); for a multiple-file array it removes the item at `index`, or all
|
|
77
|
+
* when `index` is omitted.
|
|
78
|
+
*
|
|
79
|
+
* @param prop - Name of the component property holding the pending upload(s).
|
|
80
|
+
* @param index - For array-valued properties, the item to remove; omit to remove all.
|
|
81
|
+
*/
|
|
82
|
+
@expose removeUpload(prop: string, index?: number): void {
|
|
83
|
+
const self = this as unknown as Record<string, unknown>;
|
|
84
|
+
const cur = self[prop];
|
|
85
|
+
if (Array.isArray(cur)) {
|
|
86
|
+
self[prop] =
|
|
87
|
+
index === undefined || index === null ? [] : cur.filter((_, i) => i !== Number(index));
|
|
88
|
+
} else {
|
|
89
|
+
self[prop] = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return WithUploads;
|
|
94
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { sign, verify } from "./sign.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The plain metadata describing a pending upload on the temp disk — the fields
|
|
5
|
+
* that travel in an {@link UploadRef} and round-trip through the snapshot.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Every field here is included in the signed payload because each one steers later
|
|
9
|
+
* I/O: `tmpPath`/`size`/`mime` identify the bytes, `originalName` derives the
|
|
10
|
+
* stored file's extension, and `tempDisk` selects the disk reads/writes resolve
|
|
11
|
+
* against.
|
|
12
|
+
*/
|
|
13
|
+
export interface TufData {
|
|
14
|
+
/** Path of the uploaded bytes on the temp disk. */
|
|
15
|
+
tmpPath: string;
|
|
16
|
+
/** The client-supplied original filename (used to derive the stored extension). */
|
|
17
|
+
originalName: string;
|
|
18
|
+
/** The upload's MIME type. */
|
|
19
|
+
mime: string;
|
|
20
|
+
/** Size of the upload in bytes. */
|
|
21
|
+
size: number;
|
|
22
|
+
/** Name of the temp disk the bytes live on (default disk when omitted). */
|
|
23
|
+
tempDisk?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the `/__flow/upload` endpoint returns and the client `$set`s onto a
|
|
28
|
+
* component property — {@link TufData} plus a self-describing marker and its own
|
|
29
|
+
* HMAC signature.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* The signature lets the server trust a client-supplied ref: on `$set` it is
|
|
33
|
+
* verified (see {@link TemporaryUploadedFile.fromSignedRef}) before wrapping into
|
|
34
|
+
* a {@link TemporaryUploadedFile}. A forged or altered ref fails verification and
|
|
35
|
+
* is discarded.
|
|
36
|
+
*/
|
|
37
|
+
export interface UploadRef extends TufData {
|
|
38
|
+
/** Discriminator marking this object as a flow upload reference. */
|
|
39
|
+
__flow_upload: true;
|
|
40
|
+
/** HMAC signature over the signed payload of {@link TufData} fields. */
|
|
41
|
+
sig: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function payload(d: TufData): string {
|
|
45
|
+
// Every field that steers later I/O MUST be signed:
|
|
46
|
+
// - originalName → store() derives the permanent file's extension from it, so
|
|
47
|
+
// an unsigned name lets a client swap a validated .png ref for an .html/.svg
|
|
48
|
+
// one (stored-XSS / content-type confusion on a public disk).
|
|
49
|
+
// - tempDisk → bytes()/store()/temporaryUrl() resolve against it, so an
|
|
50
|
+
// unsigned value lets a client redirect reads/writes to a different disk.
|
|
51
|
+
return `${d.tmpPath}|${d.size}|${d.mime}|${d.originalName}|${d.tempDisk ?? ""}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a signed reference (server side, in the upload endpoint). */
|
|
55
|
+
export function makeSignedRef(d: TufData): UploadRef {
|
|
56
|
+
return { __flow_upload: true, ...d, sig: sign(payload(d)) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isUploadRef(v: unknown): v is UploadRef {
|
|
60
|
+
return (
|
|
61
|
+
typeof v === "object" &&
|
|
62
|
+
v !== null &&
|
|
63
|
+
(v as Record<string, unknown>)["__flow_upload"] === true &&
|
|
64
|
+
typeof (v as Record<string, unknown>)["sig"] === "string" &&
|
|
65
|
+
typeof (v as Record<string, unknown>)["tmpPath"] === "string"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Disk = {
|
|
70
|
+
getBuffer(path: string): Promise<Uint8Array>;
|
|
71
|
+
put(path: string, data: Uint8Array, opts?: { contentType?: string }): Promise<void>;
|
|
72
|
+
delete(path: string): Promise<void>;
|
|
73
|
+
url(path: string): string;
|
|
74
|
+
temporaryUrl?(path: string, ttlSeconds: number): string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
async function disk(name?: string): Promise<Disk> {
|
|
78
|
+
const { Storage } = (await import("@zerotal/core/storage")) as unknown as {
|
|
79
|
+
Storage: { disk(n?: string): Disk };
|
|
80
|
+
};
|
|
81
|
+
return Storage.disk(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A Livewire-style temporary uploaded file — the server-side handle a component
|
|
86
|
+
* receives for a pending upload before it's promoted to permanent storage.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* Uploads bypass the WebSocket: the browser POSTs the bytes to `/__flow/upload`
|
|
90
|
+
* over HTTP, the endpoint stores them on a temp disk and returns a signed
|
|
91
|
+
* {@link UploadRef}, and the client `$set`s that ref onto a component property.
|
|
92
|
+
* The base Component verifies the signature and wraps it into a
|
|
93
|
+
* `TemporaryUploadedFile` (see {@link resolveUploadValue}). Component code then
|
|
94
|
+
* inspects it ({@link isImage}, {@link extension}, {@link temporaryUrl}) and calls
|
|
95
|
+
* {@link store} in an action to move it to permanent storage. The value
|
|
96
|
+
* round-trips through the HMAC-signed snapshot via its synth, so it survives
|
|
97
|
+
* across requests until stored.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* class Avatar extends ComponentWith(FileUploads) {
|
|
102
|
+
* @expose photo: TemporaryUploadedFile | null = null;
|
|
103
|
+
* @expose path = "";
|
|
104
|
+
*
|
|
105
|
+
* @expose async save() {
|
|
106
|
+
* if (this.photo?.isImage()) {
|
|
107
|
+
* this.path = await this.photo.store("avatars"); // -> "avatars/<uuid>.png"
|
|
108
|
+
* this.photo = null;
|
|
109
|
+
* }
|
|
110
|
+
* }
|
|
111
|
+
* }
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export class TemporaryUploadedFile {
|
|
115
|
+
/**
|
|
116
|
+
* Path of the uploaded bytes on the temp disk.
|
|
117
|
+
* @category Metadata
|
|
118
|
+
*/
|
|
119
|
+
readonly tmpPath: string;
|
|
120
|
+
/**
|
|
121
|
+
* The client-supplied original filename.
|
|
122
|
+
* @category Metadata
|
|
123
|
+
*/
|
|
124
|
+
readonly originalName: string;
|
|
125
|
+
/**
|
|
126
|
+
* The upload's MIME type.
|
|
127
|
+
* @category Metadata
|
|
128
|
+
*/
|
|
129
|
+
readonly mime: string;
|
|
130
|
+
/**
|
|
131
|
+
* Size of the upload in bytes.
|
|
132
|
+
* @category Metadata
|
|
133
|
+
*/
|
|
134
|
+
readonly size: number;
|
|
135
|
+
/**
|
|
136
|
+
* Name of the temp disk the bytes live on (default disk when undefined).
|
|
137
|
+
* @category Metadata
|
|
138
|
+
*/
|
|
139
|
+
readonly tempDisk?: string | undefined;
|
|
140
|
+
|
|
141
|
+
constructor(d: TufData) {
|
|
142
|
+
this.tmpPath = d.tmpPath;
|
|
143
|
+
this.originalName = d.originalName;
|
|
144
|
+
this.mime = d.mime;
|
|
145
|
+
this.size = d.size;
|
|
146
|
+
this.tempDisk = d.tempDisk;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The original filename (alias of {@link originalName}).
|
|
151
|
+
* @category Metadata
|
|
152
|
+
*/
|
|
153
|
+
get name(): string {
|
|
154
|
+
return this.originalName;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether the upload's MIME type is an image (`image/*`).
|
|
158
|
+
* @category Metadata
|
|
159
|
+
*/
|
|
160
|
+
isImage(): boolean {
|
|
161
|
+
return this.mime.startsWith("image/");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The lowercased file extension derived from the original name, sanitized to
|
|
165
|
+
* `[a-z0-9]` only (so a crafted name can't smuggle path/control characters into
|
|
166
|
+
* the stored filename). Empty string when the name has no extension.
|
|
167
|
+
* @returns The sanitized extension without the leading dot.
|
|
168
|
+
* @category Metadata
|
|
169
|
+
*/
|
|
170
|
+
extension(): string {
|
|
171
|
+
const parts = this.originalName.split(".");
|
|
172
|
+
if (parts.length < 2) return "";
|
|
173
|
+
// Strip anything but [a-z0-9] so a crafted name (e.g. "x.php%00", "x./..")
|
|
174
|
+
// cannot smuggle path or control characters into the stored filename.
|
|
175
|
+
return parts
|
|
176
|
+
.pop()!
|
|
177
|
+
.toLowerCase()
|
|
178
|
+
.replace(/[^a-z0-9]/g, "");
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Extract the plain {@link TufData} for serialization (used by the snapshot synth).
|
|
182
|
+
* @returns The upload's metadata without any signature.
|
|
183
|
+
* @category Storage
|
|
184
|
+
*/
|
|
185
|
+
toRef(): TufData {
|
|
186
|
+
return {
|
|
187
|
+
tmpPath: this.tmpPath,
|
|
188
|
+
originalName: this.originalName,
|
|
189
|
+
mime: this.mime,
|
|
190
|
+
size: this.size,
|
|
191
|
+
tempDisk: this.tempDisk,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Verify a client-supplied signed ref and wrap it. Returns null when the
|
|
197
|
+
* signature is invalid (a forged or tampered ref), so callers discard it rather
|
|
198
|
+
* than trusting attacker-controlled paths.
|
|
199
|
+
* @param ref - The signed reference the client `$set`.
|
|
200
|
+
* @returns A verified file, or `null` if the signature check fails.
|
|
201
|
+
* @category Storage
|
|
202
|
+
*/
|
|
203
|
+
static fromSignedRef(ref: UploadRef): TemporaryUploadedFile | null {
|
|
204
|
+
if (!verify(payload(ref), ref.sig)) return null;
|
|
205
|
+
return new TemporaryUploadedFile(ref);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Rebuild from snapshot data that is already HMAC-trusted (the whole snapshot is
|
|
209
|
+
* signed), so no per-ref signature check is needed. Used by the snapshot synth.
|
|
210
|
+
* @param data - Trusted upload metadata from the snapshot.
|
|
211
|
+
* @category Storage
|
|
212
|
+
*/
|
|
213
|
+
static fromTrustedRef(data: TufData): TemporaryUploadedFile {
|
|
214
|
+
return new TemporaryUploadedFile(data);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read the temp file's raw bytes from its disk.
|
|
219
|
+
* @returns The upload's contents.
|
|
220
|
+
* @category Storage
|
|
221
|
+
*/
|
|
222
|
+
async bytes(): Promise<Uint8Array> {
|
|
223
|
+
return (await disk(this.tempDisk)).getBuffer(this.tmpPath);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Move the temp file to permanent storage and delete the temp copy.
|
|
228
|
+
*
|
|
229
|
+
* @param directory - Destination directory on the target disk (trailing slashes trimmed).
|
|
230
|
+
* @param diskName - Target storage disk; the default disk when omitted.
|
|
231
|
+
* @param filename - Explicit filename; when omitted, a random UUID plus the
|
|
232
|
+
* sanitized {@link extension} is used.
|
|
233
|
+
* @returns The stored path (`directory/filename`).
|
|
234
|
+
* @category Storage
|
|
235
|
+
*/
|
|
236
|
+
async store(directory: string, diskName?: string, filename?: string): Promise<string> {
|
|
237
|
+
const ext = this.extension();
|
|
238
|
+
const name = filename ?? `${crypto.randomUUID()}${ext ? "." + ext : ""}`;
|
|
239
|
+
const dest = `${directory.replace(/\/+$/, "")}/${name}`;
|
|
240
|
+
const temp = await disk(this.tempDisk);
|
|
241
|
+
const target = await disk(diskName);
|
|
242
|
+
const bytes = await temp.getBuffer(this.tmpPath);
|
|
243
|
+
await target.put(dest, bytes, { contentType: this.mime });
|
|
244
|
+
await temp.delete(this.tmpPath).catch(() => {});
|
|
245
|
+
return dest;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A URL for previewing the still-temporary file — signed and expiring on disks
|
|
250
|
+
* that support it, falling back to a plain URL otherwise.
|
|
251
|
+
* @param ttlSeconds - Lifetime of the temporary URL in seconds (default `300`).
|
|
252
|
+
* @returns A URL to the temp file.
|
|
253
|
+
* @category Storage
|
|
254
|
+
*/
|
|
255
|
+
async temporaryUrl(ttlSeconds = 300): Promise<string> {
|
|
256
|
+
const d = await disk(this.tempDisk);
|
|
257
|
+
return d.temporaryUrl ? d.temporaryUrl(this.tmpPath, ttlSeconds) : d.url(this.tmpPath);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Used by Component.$set: convert client-supplied upload refs (single or array) into
|
|
263
|
+
* `TemporaryUploadedFile` instances, verifying signatures. Non-upload values pass through.
|
|
264
|
+
* A forged/invalid ref resolves to null (clearing the property) rather than trusting it.
|
|
265
|
+
*/
|
|
266
|
+
export function resolveUploadValue(value: unknown): unknown {
|
|
267
|
+
if (isUploadRef(value)) return TemporaryUploadedFile.fromSignedRef(value);
|
|
268
|
+
if (Array.isArray(value) && value.some(isUploadRef)) {
|
|
269
|
+
return value
|
|
270
|
+
.map((v) => (isUploadRef(v) ? TemporaryUploadedFile.fromSignedRef(v) : v))
|
|
271
|
+
.filter((v) => v !== null);
|
|
272
|
+
}
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// HMAC signing for upload references — same APP_KEY scheme as snapshot signing
|
|
2
|
+
// (see dehydrate.ts), so temp-file tokens are consistent and tamper-proof.
|
|
3
|
+
import { safeEqual, hmacHex } from "@zerotal/core";
|
|
4
|
+
|
|
5
|
+
function appKey(): string {
|
|
6
|
+
const key = Bun.env["APP_KEY"];
|
|
7
|
+
if (!key) throw new Error("[Flow] APP_KEY is not set. Upload signing requires APP_KEY.");
|
|
8
|
+
return key;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function sign(payload: string): string {
|
|
12
|
+
return hmacHex(payload, appKey());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function verify(payload: string, sig: string): boolean {
|
|
16
|
+
// Constant-time comparison (digest length is a fixed public constant).
|
|
17
|
+
return safeEqual(sign(payload), sig);
|
|
18
|
+
}
|
package/src/urlSafety.ts
ADDED
|
Binary file
|