@ubean/routes 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,511 @@
1
+ import { ACTION_BRAND, ActionError, isActionFailure } from "@ubean/shared";
2
+ //#region src/actions/id.ts
3
+ /**
4
+ * Stable Action ID generation (P9-02).
5
+ *
6
+ * Action IDs are derived from the action's source location (file path +
7
+ * export name) so the client and server agree on the same ID without
8
+ * runtime coordination. The Vite plugin injects the ID at build time,
9
+ * but a runtime fallback is provided for actions defined outside the
10
+ * `'use server'` pipeline (e.g. inline `defineAction` in page modules).
11
+ *
12
+ * Format: `act_<base32(sha1(relPath:exportName)).slice(0, 12)>`
13
+ * - `act_` prefix avoids collisions with other URL components
14
+ * - 12 chars of base32 gives ~60 bits of entropy (collision-resistant
15
+ * for any reasonable project size)
16
+ * - base32 (RFC 4648, lowercase) is URL-safe and case-insensitive
17
+ */
18
+ const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
19
+ /**
20
+ * Compute a stable action ID from a file path and export name.
21
+ *
22
+ * @param filePath Project-relative file path (e.g. `src/actions/auth.ts`)
23
+ * @param exportName The export name (e.g. `login`, `default`)
24
+ * @returns A stable action ID string
25
+ */
26
+ function createActionId(filePath, exportName) {
27
+ return `act_${base32HashSync(`${filePath}:${exportName}`).slice(0, 12)}`;
28
+ }
29
+ /**
30
+ * Synchronous base32 hash. Uses a simple FNV-1a hash (32-bit) when
31
+ * `crypto.subtle` is unavailable or async is undesirable. The hash space
32
+ * is smaller than SHA-1 but sufficient for action IDs within a single
33
+ * project (collision probability ~1 in 4 billion for 12 chars).
34
+ *
35
+ * For the Vite plugin path, a stronger SHA-1 hash is injected at build
36
+ * time, so this runtime fallback only affects inline `defineAction` calls
37
+ * without a file context.
38
+ */
39
+ function base32HashSync(input) {
40
+ let hash = 2166136261;
41
+ for (let i = 0; i < input.length; i++) {
42
+ hash ^= input.charCodeAt(i);
43
+ hash = Math.imul(hash, 16777619);
44
+ }
45
+ let hash2 = 16777619;
46
+ for (let i = 0; i < input.length; i++) {
47
+ hash2 ^= input.charCodeAt(i) * 31;
48
+ hash2 = Math.imul(hash2, 16777619);
49
+ }
50
+ return bigIntToBase32(BigInt(hash >>> 0) * (1n << 28n) + BigInt(hash2 >>> 0), 12);
51
+ }
52
+ function bigIntToBase32(value, length) {
53
+ let result = "";
54
+ let v = value;
55
+ for (let i = 0; i < length; i++) {
56
+ result = BASE32_ALPHABET[Number(v & 31n)] + result;
57
+ v >>= 5n;
58
+ }
59
+ return result;
60
+ }
61
+ /**
62
+ * Validate that a string is a well-formed action ID.
63
+ *
64
+ * Used by the dispatcher to reject malformed requests early.
65
+ */
66
+ function isValidActionId(id) {
67
+ return /^act_[a-z2-7]{12}$/.test(id);
68
+ }
69
+ //#endregion
70
+ //#region src/actions/registry.ts
71
+ const _registry = /* @__PURE__ */ new Map();
72
+ /**
73
+ * Register a server action in the global registry.
74
+ *
75
+ * Called by `defineAction` (implicitly) and by the Vite plugin's virtual
76
+ * module (explicitly) for `'use server'` modules.
77
+ *
78
+ * If an action with the same ID is already registered, the call is a
79
+ * no-op (HMR-safe: re-registering the same action on hot reload doesn't
80
+ * throw).
81
+ */
82
+ function registerAction(action) {
83
+ if (!_registry.has(action.id)) _registry.set(action.id, action);
84
+ }
85
+ /**
86
+ * Look up a registered server action by ID.
87
+ */
88
+ function getAction(id) {
89
+ return _registry.get(id);
90
+ }
91
+ /**
92
+ * Check whether an action with the given ID is registered.
93
+ */
94
+ function hasAction(id) {
95
+ return _registry.has(id);
96
+ }
97
+ /**
98
+ * List all registered actions (for debugging / DevTools).
99
+ */
100
+ function listActions() {
101
+ return [..._registry.values()];
102
+ }
103
+ /**
104
+ * Clear the registry (for tests).
105
+ *
106
+ * Not intended for application code — actions are registered once at
107
+ * module load and remain for the lifetime of the process.
108
+ */
109
+ function clearActions() {
110
+ _registry.clear();
111
+ }
112
+ /**
113
+ * Register a map of actions at once (bulk registration helper for the
114
+ * Vite plugin's virtual module).
115
+ */
116
+ function registerActions(actions) {
117
+ for (const action of actions) registerAction(action);
118
+ }
119
+ //#endregion
120
+ //#region src/actions/define.ts
121
+ /**
122
+ * `defineAction` — Astro-style server action creator (P9-02).
123
+ *
124
+ * Wraps an async function as a server action with a stable ID. The action
125
+ * can be invoked:
126
+ *
127
+ * - Server-side: call the returned function directly (typed input/output).
128
+ * - Client-side: the Vite plugin replaces the export with an RPC stub that
129
+ * POSTs to `/__actions` (transparent to the caller).
130
+ * - Form (progressive enhancement): use the action's `id` as a form
131
+ * `action` attribute, e.g. `<form method="POST" action="?/login">`.
132
+ *
133
+ * ## With a schema
134
+ *
135
+ * ```ts
136
+ * import { defineAction } from 'ubean';
137
+ * import { z } from 'zod';
138
+ *
139
+ * export const login = defineAction(
140
+ * z.object({ email: z.string().email(), password: z.string() }),
141
+ * async (data, ctx) => {
142
+ * // data is typed as { email: string; password: string }
143
+ * return { user: data.email };
144
+ * }
145
+ * );
146
+ * ```
147
+ *
148
+ * ## Without a schema
149
+ *
150
+ * ```ts
151
+ * export const ping = defineAction(async (input, ctx) => {
152
+ * // input is the raw parsed body (FormData or JSON object)
153
+ * return { ok: true, echo: input };
154
+ * });
155
+ * ```
156
+ *
157
+ * ## Errors and validation failures
158
+ *
159
+ * - Throw `ActionError` to signal a user-facing error (with optional `code`).
160
+ * - Return `fail(status, { field: 'message' })` for field-level validation
161
+ * errors (SvelteKit-style).
162
+ */
163
+ function defineAction(schemaOrHandler, handlerOrOptions, options) {
164
+ let schema;
165
+ let handler;
166
+ let opts = {};
167
+ if (typeof schemaOrHandler === "function") {
168
+ handler = schemaOrHandler;
169
+ if (handlerOrOptions && typeof handlerOrOptions === "object") opts = handlerOrOptions;
170
+ } else {
171
+ schema = schemaOrHandler;
172
+ handler = handlerOrOptions;
173
+ if (options) opts = options;
174
+ }
175
+ const name = opts.name || handler.name || "anonymous";
176
+ const filePath = opts.filePath || _guessCallerPath();
177
+ const action = {
178
+ id: opts.id || createActionId(filePath, name),
179
+ handler,
180
+ schema,
181
+ name,
182
+ filePath
183
+ };
184
+ Object.defineProperty(action, ACTION_BRAND, {
185
+ value: true,
186
+ enumerable: false,
187
+ configurable: false,
188
+ writable: false
189
+ });
190
+ registerAction(action);
191
+ return action;
192
+ }
193
+ /**
194
+ * Type-safe server function. Same ID, registry, and `POST /__actions` RPC as
195
+ * `defineAction` — use this alias when the function is a loader/query as well
196
+ * as a mutation. Do not invent a second RPC.
197
+ */
198
+ const defineServerFn = defineAction;
199
+ /**
200
+ * Best-effort guess of the caller's file path for action ID generation.
201
+ *
202
+ * Uses `Error.stack` parsing (works in Node and most browsers). Falls back
203
+ * to `'inline'` when the stack is unavailable. The Vite plugin overrides
204
+ * this with the actual transformed file path.
205
+ */
206
+ function _guessCallerPath() {
207
+ const stack = (/* @__PURE__ */ new Error()).stack;
208
+ if (!stack) return "inline";
209
+ const lines = stack.split("\n");
210
+ for (let i = 3; i < lines.length; i++) {
211
+ const line = lines[i];
212
+ if (line && !line.includes("/actions/src/define.ts") && !line.includes("/actions/src/id.ts")) {
213
+ const match = line.match(/\(([^)]+)\)|at\s+(https?:\/\/\S+)/);
214
+ if (match) return (match[1] || match[2]).replace(/[?#].*$/, "").replace(/:\d+:\d+$/, "");
215
+ }
216
+ }
217
+ return "inline";
218
+ }
219
+ /**
220
+ * Parse a Request body into a plain object suitable for action handlers.
221
+ *
222
+ * - `application/json` → parsed JSON object
223
+ * - `multipart/form-data` or `application/x-www-form-urlencoded` → Object
224
+ * from FormData entries (string values, files skipped)
225
+ * - Otherwise → empty object (handler can read `ctx.request` directly)
226
+ */
227
+ async function parseActionInput(request) {
228
+ const contentType = request.headers.get("Content-Type") || "";
229
+ if (contentType.includes("application/json")) try {
230
+ const text = await request.text();
231
+ return text ? JSON.parse(text) : {};
232
+ } catch {
233
+ return {};
234
+ }
235
+ if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) try {
236
+ const formData = await request.formData();
237
+ const obj = {};
238
+ for (const [key, value] of formData.entries()) if (typeof value === "string") obj[key] = value;
239
+ return obj;
240
+ } catch {
241
+ return {};
242
+ }
243
+ return {};
244
+ }
245
+ /**
246
+ * Validate input against an action schema.
247
+ *
248
+ * Returns `{ success: true, data }` on success, or
249
+ * `{ success: false, errors }` on failure (per-field messages).
250
+ */
251
+ function issuesToErrors(issues) {
252
+ const errors = {};
253
+ for (const issue of issues || []) {
254
+ const key = Array.isArray(issue.path) && issue.path.length > 0 ? String(issue.path[0]) : "_error";
255
+ errors[key] = issue.message || "Invalid value";
256
+ }
257
+ if (Object.keys(errors).length === 0) errors._error = "Invalid value";
258
+ return errors;
259
+ }
260
+ function validateActionInput(schema, input) {
261
+ const standard = schema["~standard"];
262
+ if (standard && typeof standard.validate === "function") {
263
+ const result = standard.validate(input);
264
+ if (result && typeof result === "object" && "then" in result) return {
265
+ success: false,
266
+ errors: { _error: "Async Standard Schema validation is not supported in actions" }
267
+ };
268
+ if (result && typeof result === "object" && "issues" in result && result.issues) return {
269
+ success: false,
270
+ errors: issuesToErrors(result.issues)
271
+ };
272
+ if (result && typeof result === "object" && "value" in result) return {
273
+ success: true,
274
+ data: result.value
275
+ };
276
+ }
277
+ if (schema.safeParse) {
278
+ const result = schema.safeParse(input);
279
+ if (result.success) return {
280
+ success: true,
281
+ data: result.data
282
+ };
283
+ const errors = {};
284
+ const issues = result.error?.issues || [];
285
+ for (const issue of issues) {
286
+ const key = issue.message ? String(issue.message) : "Invalid";
287
+ errors[key] = issue.message || "Invalid value";
288
+ }
289
+ return {
290
+ success: false,
291
+ errors
292
+ };
293
+ }
294
+ try {
295
+ if (schema.parse) return {
296
+ success: true,
297
+ data: schema.parse(input)
298
+ };
299
+ return {
300
+ success: true,
301
+ data: input
302
+ };
303
+ } catch (err) {
304
+ return {
305
+ success: false,
306
+ errors: { _error: err instanceof Error ? err.message : String(err) }
307
+ };
308
+ }
309
+ }
310
+ /**
311
+ * Normalize a handler return value into an `ActionResult`.
312
+ *
313
+ * - `ActionFailure` → `{ errors, status: failure.status }`
314
+ * - `ActionError` (thrown) → `{ error: { message, code }, status }`
315
+ * - Other thrown error → `{ error: { message }, status: 500 }`
316
+ * - Plain value → `{ data: value, status: 200 }`
317
+ */
318
+ function normalizeActionResult(result, error, failureStatus = 400) {
319
+ if (error) {
320
+ if (error instanceof ActionError) return {
321
+ error: {
322
+ message: error.message,
323
+ code: error.code
324
+ },
325
+ status: error.status
326
+ };
327
+ return {
328
+ error: { message: error instanceof Error ? error.message : String(error) },
329
+ status: 500
330
+ };
331
+ }
332
+ if (isActionFailure(result)) return {
333
+ errors: result.errors,
334
+ status: result.status || failureStatus
335
+ };
336
+ return {
337
+ data: result,
338
+ status: 200
339
+ };
340
+ }
341
+ /**
342
+ * Build an `ActionContext` from a Hono context.
343
+ *
344
+ * - `request`: the underlying `c.req.raw` Request
345
+ * - `context`: the Hono context (for `c.set`, `c.get`, etc.)
346
+ * - `params`: route params from `c.req.param()`
347
+ */
348
+ function buildActionContext(c) {
349
+ return {
350
+ request: c.req.raw,
351
+ context: c,
352
+ params: c.req.param()
353
+ };
354
+ }
355
+ //#endregion
356
+ //#region src/actions/request-context.ts
357
+ const STORAGE_KEY = "__UBEAN_ACTION_CTX_STORAGE__";
358
+ function bindActionContextStorage(storage) {
359
+ globalThis[STORAGE_KEY] = storage;
360
+ }
361
+ function getActionContext() {
362
+ return globalThis[STORAGE_KEY]?.getStore();
363
+ }
364
+ function runWithActionContext(ctx, fn) {
365
+ const storage = globalThis[STORAGE_KEY];
366
+ if (storage) return storage.run(ctx, fn);
367
+ return fn();
368
+ }
369
+ function createDetachedActionContext(request) {
370
+ const req = request ?? new Request("http://ubean.local/__actions");
371
+ const params = {};
372
+ return {
373
+ request: req,
374
+ context: {
375
+ req: {
376
+ raw: req,
377
+ param: () => params,
378
+ path: new URL(req.url).pathname,
379
+ method: req.method,
380
+ header: (name) => req.headers.get(name) ?? void 0
381
+ },
382
+ get: () => void 0,
383
+ set: () => void 0
384
+ },
385
+ params
386
+ };
387
+ }
388
+ //#endregion
389
+ //#region src/actions/invoke.ts
390
+ /**
391
+ * Isomorphic invocation for `defineAction` / `defineServerFn`.
392
+ *
393
+ * Same ID and `/__actions` RPC as Server Actions — not a second protocol.
394
+ * Server with ALS: call the real handler. Otherwise (client stub or no
395
+ * request scope): call `handler`, which on the client is the Vite RPC stub.
396
+ */
397
+ function unwrapActionResult(result) {
398
+ if (result.error) throw new ActionError(result.error.message, {
399
+ code: result.error.code,
400
+ status: result.status
401
+ });
402
+ if (result.errors) {
403
+ const first = Object.values(result.errors)[0] || "Validation failed";
404
+ throw new ActionError(first, { status: result.status || 400 });
405
+ }
406
+ if (result.response) throw result.response;
407
+ return result.data;
408
+ }
409
+ function isActionResultShape(value) {
410
+ return typeof value === "object" && value !== null && "status" in value && ("data" in value || "error" in value || "errors" in value || "response" in value);
411
+ }
412
+ async function unwrapServerFnResult(value) {
413
+ if (isActionFailure(value)) {
414
+ const first = Object.values(value.errors)[0] || "Validation failed";
415
+ throw new ActionError(first, { status: value.status });
416
+ }
417
+ if (isActionResultShape(value)) return unwrapActionResult(value);
418
+ return value;
419
+ }
420
+ /**
421
+ * Call a server function from a loader, `useAsyncData`, or the client.
422
+ *
423
+ * Reuses the action ID / `POST /__actions` envelope. Does not invent RPC.
424
+ */
425
+ async function invokeServerFn(fn, input) {
426
+ let data = input;
427
+ if (fn.schema) {
428
+ const validated = validateActionInput(fn.schema, input);
429
+ if (!validated.success) {
430
+ const first = Object.values(validated.errors)[0] || "Validation failed";
431
+ throw new ActionError(first, { status: 400 });
432
+ }
433
+ data = validated.data;
434
+ }
435
+ const ctx = getActionContext() ?? createDetachedActionContext();
436
+ return unwrapServerFnResult(await fn.handler(data, ctx));
437
+ }
438
+ //#endregion
439
+ //#region src/actions/constants.ts
440
+ /**
441
+ * Shared constants for Server Actions RPC.
442
+ *
443
+ * Kept in a leaf module so the browser runtime can import them without
444
+ * pulling the Hono middleware barrel.
445
+ */
446
+ const ACTIONS_ENDPOINT = "/__actions";
447
+ const ACTION_RESPONSE_HEADER = "x-ubean-action";
448
+ //#endregion
449
+ //#region src/actions/form-action.ts
450
+ /**
451
+ * SvelteKit-style form action URL parsing (P9-02).
452
+ *
453
+ * Form actions are invoked via `POST /page?/<actionName>`:
454
+ *
455
+ * - `POST /login` → `actions.default`
456
+ * - `POST /login?/login` → `actions.login`
457
+ * - `POST /login?/register` → `actions.register`
458
+ *
459
+ * The `?/<name>` syntax is URL-safe and works without JavaScript (the
460
+ * browser submits the form to the full URL). On the server, the page
461
+ * route handler parses the action name from the URL's search query.
462
+ *
463
+ * For progressive enhancement, the client can intercept the form submit
464
+ * and call the action via `useFormAction()` for SPA-style navigation.
465
+ */
466
+ /**
467
+ * Extract the form action name from a URL's search query.
468
+ *
469
+ * SvelteKit's convention: the query string contains a single key `/<name>`
470
+ * with no value. URLSearchParams treats this as a key with empty value.
471
+ *
472
+ * - `?/login` → `login`
473
+ * - `?/register` → `register`
474
+ * - `?` (no action) → `default`
475
+ * - (no query) → `default`
476
+ *
477
+ * @returns The action name, or `'default'` when no specific action is
478
+ * specified.
479
+ */
480
+ function parseFormActionName(url) {
481
+ const match = (typeof url === "string" ? url : url.search).match(/[?&]\/([^&]+)/);
482
+ return match ? match[1] : "default";
483
+ }
484
+ /**
485
+ * Build a form action URL for the current page.
486
+ *
487
+ * Used by `useFormAction()` to generate the `action` attribute for
488
+ * `<form>` elements. The URL is relative so it works on any page.
489
+ *
490
+ * - `useFormAction()` → `?/default`
491
+ * - `useFormAction('login')` → `?/login`
492
+ * - `useFormAction('register')` → `?/register`
493
+ *
494
+ * The returned string is intended for `<form :action="formAction">` —
495
+ * the browser submits to the current page URL with the action name
496
+ * appended as a query parameter.
497
+ */
498
+ function buildFormActionUrl(actionName = "default") {
499
+ if (!actionName || actionName === "default") return "?/default";
500
+ return `?/${encodeURIComponent(actionName)}`;
501
+ }
502
+ /**
503
+ * Check whether a URL's query string contains a form action specifier
504
+ * (`?/<name>` pattern).
505
+ */
506
+ function hasFormAction(url) {
507
+ const search = typeof url === "string" ? url : url.search;
508
+ return /[?&]\/[^&]+/.test(search);
509
+ }
510
+ //#endregion
511
+ export { registerAction as C, isValidActionId as E, listActions as S, createActionId as T, parseActionInput as _, ACTION_RESPONSE_HEADER as a, getAction as b, unwrapServerFnResult as c, getActionContext as d, runWithActionContext as f, normalizeActionResult as g, defineServerFn as h, ACTIONS_ENDPOINT as i, bindActionContextStorage as l, defineAction as m, hasFormAction as n, invokeServerFn as o, buildActionContext as p, parseFormActionName as r, unwrapActionResult as s, buildFormActionUrl as t, createDetachedActionContext as u, validateActionInput as v, registerActions as w, hasAction as x, clearActions as y };