@rc312/tss-effect-serverfn 0.0.0-stage → 0.2.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/dist/bootstrap.server.d.ts +12 -0
  4. package/dist/bootstrap.server.d.ts.map +1 -0
  5. package/dist/bootstrap.server.js +17 -0
  6. package/dist/bootstrap.server.js.map +1 -0
  7. package/dist/boundary.server.d.ts +26 -0
  8. package/dist/boundary.server.d.ts.map +1 -0
  9. package/dist/boundary.server.js +117 -0
  10. package/dist/boundary.server.js.map +1 -0
  11. package/dist/compiler-runtime.d.ts +40 -0
  12. package/dist/compiler-runtime.d.ts.map +1 -0
  13. package/dist/compiler-runtime.js +54 -0
  14. package/dist/compiler-runtime.js.map +1 -0
  15. package/dist/create-effect-server-fn.d.ts +84 -0
  16. package/dist/create-effect-server-fn.d.ts.map +1 -0
  17. package/dist/create-effect-server-fn.js +27 -0
  18. package/dist/create-effect-server-fn.js.map +1 -0
  19. package/dist/effect-middleware.d.ts +116 -0
  20. package/dist/effect-middleware.d.ts.map +1 -0
  21. package/dist/effect-middleware.js +46 -0
  22. package/dist/effect-middleware.js.map +1 -0
  23. package/dist/execution.d.ts +35 -0
  24. package/dist/execution.d.ts.map +1 -0
  25. package/dist/execution.js +7 -0
  26. package/dist/execution.js.map +1 -0
  27. package/dist/failure.server.d.ts +44 -0
  28. package/dist/failure.server.d.ts.map +1 -0
  29. package/dist/failure.server.js +161 -0
  30. package/dist/failure.server.js.map +1 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +3 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/provenance.server.d.ts +54 -0
  36. package/dist/provenance.server.d.ts.map +1 -0
  37. package/dist/provenance.server.js +87 -0
  38. package/dist/provenance.server.js.map +1 -0
  39. package/dist/server.d.ts +3 -0
  40. package/dist/server.d.ts.map +1 -0
  41. package/dist/server.js +3 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/vite.d.ts +24 -0
  44. package/dist/vite.d.ts.map +1 -0
  45. package/dist/vite.js +296 -0
  46. package/dist/vite.js.map +1 -0
  47. package/docs/composition.md +188 -0
  48. package/docs/development.md +48 -0
  49. package/docs/usage.md +223 -0
  50. package/package.json +69 -4
  51. package/src/bootstrap.server.ts +22 -0
  52. package/src/boundary.server.ts +198 -0
  53. package/src/compiler-runtime.ts +75 -0
  54. package/src/create-effect-server-fn.ts +197 -0
  55. package/src/effect-middleware.ts +171 -0
  56. package/src/execution.ts +39 -0
  57. package/src/failure.server.ts +257 -0
  58. package/src/index.ts +12 -0
  59. package/src/provenance.server.ts +125 -0
  60. package/src/server.ts +7 -0
  61. package/src/vite.ts +462 -0
@@ -0,0 +1,125 @@
1
+ import { Predicate } from "effect";
2
+ import type { Cause } from "effect";
3
+
4
+ /**
5
+ * Reporting ownership for one rejection identity: suppress all capture, mark
6
+ * boundary handling, or leave reporting to the native framework.
7
+ */
8
+ type Disposition = "suppress" | "boundary" | "native";
9
+
10
+ interface FailureState {
11
+ readonly disposition: Disposition;
12
+ readonly cause?: Cause.Cause<unknown>;
13
+ accepted: boolean;
14
+ }
15
+
16
+ const makeRegistry = () => ({
17
+ failures: new WeakMap<object, FailureState>(),
18
+ });
19
+
20
+ /**
21
+ * Identity-based diagnostics shared by adapters and reporting hooks.
22
+ * Weak keys avoid retaining completed failures; Vite's HMR data keeps in-flight
23
+ * rejections recognizable when this module is replaced.
24
+ */
25
+ const registry: ReturnType<typeof makeRegistry> = (() => {
26
+ if (import.meta.hot) {
27
+ import.meta.hot.data.effectServerFnProvenance ??= makeRegistry();
28
+
29
+ return import.meta.hot.data.effectServerFnProvenance;
30
+ }
31
+
32
+ return makeRegistry();
33
+ })();
34
+
35
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Lookup accepts arbitrary rejection values and narrows before touching the identity registry.
36
+ const getFailureState = (error: unknown) =>
37
+ Predicate.isObject(error) ? registry.failures.get(error) : undefined;
38
+
39
+ /**
40
+ * Register diagnostics and reporting ownership without mutating a rejection.
41
+ * Registration resets event acceptance for this identity. Boundary-created
42
+ * rejections use fresh records so separate requests are deduplicated separately.
43
+ *
44
+ * @param failure - Object used as the actual rejection identity.
45
+ * @param disposition - Whether reporting is suppressed, handled, or delegated.
46
+ * @param cause - Original diagnostic Cause; never added to the wire payload.
47
+ * @returns The same object, suitable for returning or throwing.
48
+ * @internal
49
+ */
50
+ export const ownFailure = <P extends object>(
51
+ failure: P,
52
+ disposition: Disposition,
53
+ cause?: Cause.Cause<unknown>
54
+ ): P => {
55
+ const state = {
56
+ accepted: false,
57
+ disposition,
58
+ };
59
+
60
+ registry.failures.set(
61
+ failure,
62
+ cause === undefined ? state : { ...state, cause }
63
+ );
64
+
65
+ return failure;
66
+ };
67
+
68
+ /**
69
+ * Read the reporting policy for a rejection recognized by this server process.
70
+ *
71
+ * `capture` indicates whether an error event is allowed. `handled` indicates
72
+ * that the boundary handled or suppressed reporting, so outer middleware should
73
+ * not initiate another capture. This lookup does not consume event acceptance.
74
+ *
75
+ * @param error - The original rejection object, not a deserialized copy.
76
+ * @returns A policy snapshot, or `undefined` for an unrecognized value.
77
+ */
78
+ export const getServerFnFailureDisposition = <E>(error: E) => {
79
+ const state = getFailureState(error);
80
+
81
+ return state
82
+ ? {
83
+ capture: state.disposition !== "suppress",
84
+ handled: state.disposition !== "native",
85
+ }
86
+ : undefined;
87
+ };
88
+
89
+ /**
90
+ * Retrieve the original diagnostic Cause associated with a rejection.
91
+ * Includes bootstrap and runtime failures as well as business/cleanup failures.
92
+ * The Cause can contain private data and must remain server-local.
93
+ *
94
+ * @param error - The original rejection identity observed on the server.
95
+ * @returns Its registered Cause, or `undefined` when none is available.
96
+ */
97
+ export const getServerFnFailureCause = <E>(
98
+ error: E
99
+ ): Cause.Cause<unknown> | undefined => getFailureState(error)?.cause;
100
+
101
+ /**
102
+ * Accept at most one error-delivery event for a registered rejection identity.
103
+ * Use in a reporter's `beforeSend`-style hook, including events initiated by the
104
+ * boundary itself. Unlike the disposition lookup, this call consumes acceptance.
105
+ * Unrelated errors retain native reporting behavior.
106
+ *
107
+ * @param error - The original exception associated with the delivery event.
108
+ * @returns `false` for suppressed or already-accepted registered failures;
109
+ * `true` for the first allowed event or any unrecognized value.
110
+ */
111
+ export const acceptServerFnErrorEvent = <E>(error: E) => {
112
+ const state = getFailureState(error);
113
+
114
+ if (!state) {
115
+ return true;
116
+ }
117
+
118
+ if (state.disposition === "suppress" || state.accepted) {
119
+ return false;
120
+ }
121
+
122
+ state.accepted = true;
123
+
124
+ return true;
125
+ };
package/src/server.ts ADDED
@@ -0,0 +1,7 @@
1
+ import "@tanstack/react-start/server-only";
2
+
3
+ export {
4
+ acceptServerFnErrorEvent,
5
+ getServerFnFailureCause,
6
+ getServerFnFailureDisposition,
7
+ } from "./provenance.server.ts";
package/src/vite.ts ADDED
@@ -0,0 +1,462 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import * as babel from "@babel/core";
4
+ import * as t from "@babel/types";
5
+ import type { Plugin } from "vite";
6
+
7
+ const runtimeModule = "@rc312/tss-effect-serverfn/compiler-runtime";
8
+ const builderMethods = new Set(["middleware", "validator", "handler"]);
9
+
10
+ const staticName = (node: t.Node) => {
11
+ if (t.isIdentifier(node)) {
12
+ return node.name;
13
+ }
14
+
15
+ return t.isStringLiteral(node) ? node.value : undefined;
16
+ };
17
+
18
+ const parseModule = async (code: string, id: string) => {
19
+ const ast = await babel.parseAsync(code, {
20
+ babelrc: false,
21
+ configFile: false,
22
+ filename: id,
23
+ parserOpts: { plugins: ["typescript", "jsx"] },
24
+ sourceType: "module",
25
+ });
26
+
27
+ let program: babel.NodePath<t.Program> | undefined;
28
+
29
+ if (ast) {
30
+ babel.traverse(ast, {
31
+ Program(path) {
32
+ program = path;
33
+ path.stop();
34
+ },
35
+ });
36
+ }
37
+
38
+ if (!ast || !program) {
39
+ throw new Error(`Unable to parse Effect server functions in ${id}`);
40
+ }
41
+
42
+ return { ast, program };
43
+ };
44
+
45
+ type ParsedModule = Awaited<ReturnType<typeof parseModule>>;
46
+
47
+ type ResolveModule = (
48
+ source: string,
49
+ importer: string
50
+ ) => Promise<string | undefined>;
51
+
52
+ /**
53
+ * Resolve whether an expression originates from `createEffectServerFn`.
54
+ * Follows lexical bindings, configured builders, and application re-exports;
55
+ * Babel NodePaths preserve scope so shadowed names are not mistaken for imports.
56
+ * Traversal-local visited keys terminate alias and export cycles.
57
+ *
58
+ * @param load - Load an application module with its binding-aware Program path.
59
+ * @param resolve - Resolve imports through Vite's module-resolution rules.
60
+ * @returns An asynchronous predicate over expressions in a module.
61
+ * @throws {Error} When a recognized factory uses unsupported mutable or dynamic composition.
62
+ */
63
+ const createFactoryResolver = (
64
+ load: (id: string) => Promise<ParsedModule>,
65
+ resolve: ResolveModule
66
+ ) => {
67
+ const trace = {
68
+ async factory(
69
+ path: babel.NodePath<t.Node | null | undefined>,
70
+ id: string,
71
+ seen: ReadonlySet<string> = new Set()
72
+ ): Promise<boolean> {
73
+ if (path.isTSAsExpression()) {
74
+ return trace.factory(path.get("expression"), id, seen);
75
+ }
76
+
77
+ if (path.isTSSatisfiesExpression()) {
78
+ return trace.factory(path.get("expression"), id, seen);
79
+ }
80
+
81
+ if (path.isTSNonNullExpression()) {
82
+ return trace.factory(path.get("expression"), id, seen);
83
+ }
84
+
85
+ if (path.isCallExpression()) {
86
+ return trace.factory(path.get("callee"), id, seen);
87
+ }
88
+
89
+ if (path.isMemberExpression()) {
90
+ return trace.member(path, id, seen);
91
+ }
92
+
93
+ if (path.isImportSpecifier() && path.parentPath.isImportDeclaration()) {
94
+ const { imported } = path.node;
95
+
96
+ return trace.imported(
97
+ path.parentPath.node.source.value,
98
+ t.isIdentifier(imported) ? imported.name : imported.value,
99
+ id,
100
+ seen
101
+ );
102
+ }
103
+
104
+ if (path.isVariableDeclarator()) {
105
+ return trace.factory(path.get("init"), id, seen);
106
+ }
107
+
108
+ if (
109
+ path.isArrowFunctionExpression() &&
110
+ (await trace.factory(path.get("body"), id, seen))
111
+ ) {
112
+ throw new Error(
113
+ `Effect factory wrappers are not supported in ${id}; export a configured builder directly`
114
+ );
115
+ }
116
+
117
+ if (!path.isIdentifier()) {
118
+ return false;
119
+ }
120
+
121
+ const binding = path.scope.getBinding(path.node.name);
122
+ const key = `${id}:${binding?.scope.uid}:${path.node.name}`;
123
+
124
+ if (!binding || seen.has(key)) {
125
+ return false;
126
+ }
127
+
128
+ const found = await trace.factory(
129
+ binding.path,
130
+ id,
131
+ new Set([...seen, key])
132
+ );
133
+
134
+ if (found && !binding.constant) {
135
+ throw new Error(
136
+ `Mutable Effect factory binding ${path.node.name} in ${id}; use const`
137
+ );
138
+ }
139
+
140
+ return found;
141
+ },
142
+
143
+ async imported(
144
+ source: string,
145
+ name: string,
146
+ importer: string,
147
+ seen: ReadonlySet<string>
148
+ ): Promise<boolean> {
149
+ if (source === "@rc312/tss-effect-serverfn") {
150
+ return name === "createEffectServerFn";
151
+ }
152
+
153
+ const id = await resolve(source, importer);
154
+
155
+ if (
156
+ !id ||
157
+ id.includes("/node_modules/") ||
158
+ id.startsWith("\0") ||
159
+ seen.has(`${id}:${name}`)
160
+ ) {
161
+ return false;
162
+ }
163
+
164
+ const next = new Set([...seen, `${id}:${name}`]);
165
+ const { program } = await load(id);
166
+ const stars: string[] = [];
167
+
168
+ for (const statement of program.get("body")) {
169
+ if (statement.isExportAllDeclaration()) {
170
+ stars.push(statement.node.source.value);
171
+ }
172
+
173
+ if (!statement.isExportNamedDeclaration()) {
174
+ continue;
175
+ }
176
+
177
+ for (const specifier of statement.get("specifiers")) {
178
+ if (
179
+ specifier.isExportSpecifier() &&
180
+ staticName(specifier.node.exported) === name
181
+ ) {
182
+ return statement.node.source
183
+ ? trace.imported(
184
+ statement.node.source.value,
185
+ specifier.node.local.name,
186
+ id,
187
+ next
188
+ )
189
+ : trace.factory(specifier.get("local"), id, next);
190
+ }
191
+ }
192
+
193
+ const declaration = statement.get("declaration");
194
+
195
+ if (declaration.isVariableDeclaration()) {
196
+ const exported = declaration
197
+ .get("declarations")
198
+ .find((item) => item.get("id").isIdentifier({ name }));
199
+
200
+ if (exported) {
201
+ return trace.factory(exported.get("id"), id, next);
202
+ }
203
+ }
204
+ }
205
+
206
+ const matches = await Promise.all(
207
+ stars.map((star) => trace.imported(star, name, id, next))
208
+ );
209
+
210
+ return matches.some(Boolean);
211
+ },
212
+
213
+ async member(
214
+ path: babel.NodePath<t.MemberExpression>,
215
+ id: string,
216
+ seen: ReadonlySet<string>
217
+ ): Promise<boolean> {
218
+ const object = path.get("object");
219
+
220
+ if (await trace.factory(object, id, seen)) {
221
+ if (
222
+ path.node.computed ||
223
+ !t.isIdentifier(path.node.property) ||
224
+ !builderMethods.has(path.node.property.name)
225
+ ) {
226
+ throw new Error(
227
+ `Unsupported Effect builder composition in ${id}; use static .middleware(), .validator(), and .handler() calls`
228
+ );
229
+ }
230
+
231
+ return true;
232
+ }
233
+
234
+ const binding = object.isIdentifier()
235
+ ? object.scope.getBinding(object.node.name)
236
+ : undefined;
237
+
238
+ if (
239
+ !path.node.computed &&
240
+ t.isIdentifier(path.node.property) &&
241
+ binding?.path.isImportNamespaceSpecifier() &&
242
+ binding.path.parentPath.isImportDeclaration()
243
+ ) {
244
+ return trace.imported(
245
+ binding.path.parentPath.node.source.value,
246
+ path.node.property.name,
247
+ id,
248
+ seen
249
+ );
250
+ }
251
+
252
+ return false;
253
+ },
254
+ };
255
+
256
+ return trace.factory;
257
+ };
258
+
259
+ /** Check helper import identity, rather than its local name, to keep lowering idempotent. */
260
+ const isAlreadyLowered = (argument: babel.NodePath, helper: string) => {
261
+ if (!argument.isCallExpression() || !t.isIdentifier(argument.node.callee)) {
262
+ return false;
263
+ }
264
+
265
+ const binding = argument.scope.getBinding(argument.node.callee.name);
266
+
267
+ return (
268
+ binding?.path.isImportSpecifier() &&
269
+ t.isIdentifier(binding.path.node.imported, { name: helper }) &&
270
+ binding.path.parentPath.isImportDeclaration() &&
271
+ binding.path.parentPath.node.source.value === runtimeModule
272
+ );
273
+ };
274
+
275
+ /** Collect syntactic candidates; binding analysis must approve them before rewriting. */
276
+ const collectBuilderCalls = (program: babel.NodePath<t.Program>) => {
277
+ const candidates: babel.NodePath<t.CallExpression>[] = [];
278
+ program.traverse({
279
+ CallExpression(path) {
280
+ const { callee } = path.node;
281
+
282
+ if (
283
+ t.isMemberExpression(callee) &&
284
+ ["validator", "handler"].includes(staticName(callee.property) ?? "")
285
+ ) {
286
+ candidates.push(path);
287
+ }
288
+ },
289
+ });
290
+
291
+ return candidates;
292
+ };
293
+
294
+ /**
295
+ * Wrap validated builder arguments with generated runtime helpers in place.
296
+ * Input paths must already be resolved to Effect builders. Fresh import names
297
+ * avoid lexical collisions; previously lowered arguments remain untouched.
298
+ *
299
+ * @returns Whether any AST nodes and helper imports were added or changed.
300
+ * @throws {Error} For computed methods or calls without exactly one expression argument.
301
+ */
302
+ const lowerBuilderCalls = (
303
+ calls: readonly babel.NodePath<t.CallExpression>[],
304
+ program: babel.NodePath<t.Program>,
305
+ id: string
306
+ ) => {
307
+ const helpers = new Map<string, t.Identifier>();
308
+
309
+ for (const call of calls) {
310
+ const { callee } = call.node;
311
+
312
+ if (
313
+ !t.isMemberExpression(callee) ||
314
+ callee.computed ||
315
+ !t.isIdentifier(callee.property)
316
+ ) {
317
+ throw new Error(
318
+ `Computed Effect builder methods are unsupported in ${id}`
319
+ );
320
+ }
321
+
322
+ const [argument] = call.get("arguments");
323
+
324
+ if (call.node.arguments.length !== 1 || !argument?.isExpression()) {
325
+ throw new Error(
326
+ `Effect builders require one schema or handler expression before TanStack compilation in ${id}`
327
+ );
328
+ }
329
+
330
+ const helper =
331
+ callee.property.name === "validator" ? "prepareInput" : "effectHandler";
332
+
333
+ if (isAlreadyLowered(argument, helper)) {
334
+ continue;
335
+ }
336
+
337
+ let local = helpers.get(helper);
338
+
339
+ if (!local) {
340
+ local = program.scope.generateUidIdentifier(helper);
341
+ helpers.set(helper, local);
342
+ }
343
+
344
+ argument.replaceWith(t.callExpression(local, [argument.node]));
345
+ }
346
+
347
+ if (helpers.size === 0) {
348
+ return false;
349
+ }
350
+
351
+ program.unshiftContainer(
352
+ "body",
353
+ t.importDeclaration(
354
+ Array.from(helpers, ([name, local]) =>
355
+ t.importSpecifier(local, t.identifier(name))
356
+ ),
357
+ t.stringLiteral(runtimeModule)
358
+ )
359
+ );
360
+
361
+ return true;
362
+ };
363
+
364
+ /**
365
+ * Adapt Effect Schemas and handlers before TanStack Start compiles server functions.
366
+ *
367
+ * Install before `tanstackStart()`. The plugin recognizes static builder chains
368
+ * and transparent aliases/re-exports, wraps validator and handler arguments, and
369
+ * emits source maps. Start remains responsible for extraction, RPC identifiers,
370
+ * manifests, and removal of server implementations from browser bundles.
371
+ *
372
+ * @returns A Vite pre-transform plugin with build/watch module-cache invalidation.
373
+ *
374
+ * @example
375
+ * ```ts
376
+ * import { tanstackStart } from "@tanstack/react-start/plugin/vite";
377
+ * import { effectServerFns } from "@rc312/tss-effect-serverfn/vite";
378
+ * import { defineConfig } from "vite";
379
+ *
380
+ * export default defineConfig({
381
+ * plugins: [effectServerFns(), tanstackStart()],
382
+ * });
383
+ * ```
384
+ */
385
+ export const effectServerFns = (): Plugin => {
386
+ const modules = new Map<string, Promise<ParsedModule>>();
387
+
388
+ const load = (id: string) => {
389
+ let module = modules.get(id);
390
+
391
+ if (!module) {
392
+ module = (async () => parseModule(await readFile(id, "utf-8"), id))();
393
+ modules.set(id, module);
394
+ }
395
+
396
+ return module;
397
+ };
398
+
399
+ return {
400
+ buildStart() {
401
+ modules.clear();
402
+ },
403
+ enforce: "pre",
404
+ name: "@rc312/tss-effect-serverfn",
405
+ async transform(code, id) {
406
+ if (
407
+ !/\.[cm]?[jt]sx?(?:\?|$)/u.test(id) ||
408
+ !/(?:validator|handler)/u.test(code)
409
+ ) {
410
+ return;
411
+ }
412
+
413
+ const cleanId = id.split("?")[0] ?? id;
414
+ const { ast, program } = await parseModule(code, cleanId);
415
+ const candidates = collectBuilderCalls(program);
416
+
417
+ const isFactory = createFactoryResolver(
418
+ load,
419
+ async (source, importer) => {
420
+ const resolved = await this.resolve(source, importer, {
421
+ skipSelf: true,
422
+ });
423
+
424
+ return resolved?.id;
425
+ }
426
+ );
427
+
428
+ // Finish binding analysis before mutating paths shared by nested builder calls.
429
+ const resolvedCalls = await Promise.all(
430
+ candidates.map(async (candidate) => {
431
+ const callee = candidate.get("callee");
432
+
433
+ return callee.isMemberExpression() &&
434
+ (await isFactory(callee.get("object"), cleanId))
435
+ ? candidate
436
+ : undefined;
437
+ })
438
+ );
439
+
440
+ const effectCalls = resolvedCalls.filter((call) => call !== undefined);
441
+
442
+ if (!lowerBuilderCalls(effectCalls, program, id)) {
443
+ return;
444
+ }
445
+
446
+ const result = await babel.transformFromAstAsync(ast, code, {
447
+ babelrc: false,
448
+ configFile: false,
449
+ filename: cleanId,
450
+ sourceFileName: cleanId,
451
+ sourceMaps: true,
452
+ });
453
+
454
+ if (result?.code) {
455
+ return { code: result.code, map: result.map ?? null };
456
+ }
457
+ },
458
+ watchChange() {
459
+ modules.clear();
460
+ },
461
+ };
462
+ };