Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/effect-v4-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@uploadthing/shared": major
"uploadthing": major
"@example/backend-adapters-server": patch
"next-playground": patch
"next-playground-v6": patch
"@uploadthing/tsconfig": patch
---

feat!: upgrade to Effect v4

- `effect` is bumped from `3.21.0` to `4.0.0-beta.93`. Since `@effect/platform`
has been merged into the core `effect` package in v4, the separate
`@effect/platform` dependency is removed and all HTTP modules are now
imported from `effect/unstable/http`.
- Client-side code previously built on `effect/Micro` (which was removed in v4)
now uses the core `Effect` module, which is aggressively tree-shakeable in v4.
- The `uploadthing/effect-platform` adapter now returns a plain
`Effect<HttpServerResponse, unknown, HttpClient | HttpServerRequest | Scope>`
handler that can be registered on an Effect v4 `HttpRouter` (e.g. via
`HttpRouter.add("*", "/api/uploadthing", handler)`).
- Public types that referenced Effect v3 concepts follow the v4 renames, e.g.
`logLevel` options are now typed as `LogLevel.LogLevel` string literals.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.14
22.23
5 changes: 2 additions & 3 deletions examples/backend-adapters/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@
"dev:effect": "NODE_ENV=development PORT=3003 tsx watch src/effect-platform.ts"
},
"dependencies": {
"@effect/platform": "0.96.0",
"@effect/platform-node": "0.106.0",
"@effect/platform-node": "4.0.0-beta.93",
"@elysiajs/cors": "^1.2.0",
"@fastify/cors": "^10.0.1",
"@hono/node-server": "^1.13.7",
"@sinclair/typebox": "^0.34.13",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"effect": "3.21.0",
"effect": "4.0.0-beta.93",
"elysia": "^1.2.9",
"express": "^5.0.1",
"fastify": "^5.2.0",
Expand Down
72 changes: 27 additions & 45 deletions examples/backend-adapters/server/src/effect-platform.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,52 @@
import "dotenv/config";

import { createServer } from "node:http";
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { Config, Effect, Layer, References } from "effect";
import {
FetchHttpClient,
Headers,
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "@effect/platform";
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { Config, Effect, Layer, Logger, LogLevel } from "effect";
} from "effect/unstable/http";

import { createRouteHandler } from "uploadthing/effect-platform";

import { uploadRouter } from "./router";

const uploadthingRouter = createRouteHandler({
const uploadthingHandler = createRouteHandler({
router: uploadRouter,
});

/**
* Simple CORS middleware that allows everything
* Adjust to your needs.
*/
const cors = HttpMiddleware.make((app) =>
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest;

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
};

if (req.method === "OPTIONS") {
return HttpServerResponse.empty({
status: 204,
headers: Headers.fromInput(corsHeaders),
});
}

const response = yield* app;

return response.pipe(HttpServerResponse.setHeaders(corsHeaders));
const Routes = Layer.mergeAll(
HttpRouter.add("GET", "/api", HttpServerResponse.text("Hello from Effect")),
HttpRouter.add(
"*",
"/api/uploadthing",
uploadthingHandler.pipe(Effect.orDie),
),
/**
* Simple CORS middleware that allows everything.
* Adjust to your needs.
*/
HttpRouter.cors({
allowedOrigins: ["*"],
allowedMethods: ["*"],
allowedHeaders: ["*"],
}),
);

const router = HttpRouter.empty.pipe(
HttpRouter.get("/api", HttpServerResponse.text("Hello from Effect")),
HttpRouter.mountApp("/api/uploadthing", uploadthingRouter),
const ServerLive = Layer.unwrap(
Effect.map(Config.port("PORT").pipe(Config.withDefault(3000)), (port) =>
NodeHttpServer.layer(() => createServer(), { port }),
),
);

const app = router.pipe(
cors,
HttpServer.serve(HttpMiddleware.logger),
const AppLive = HttpRouter.serve(Routes).pipe(
HttpServer.withLogAddress,
Layer.provide(FetchHttpClient.layer),
Layer.provide(ServerLive),
Layer.provide(Layer.succeed(References.MinimumLogLevel, "Debug")),
);

const Port = Config.integer("PORT").pipe(Config.withDefault(3000));
const ServerLive = Layer.unwrapEffect(
Effect.map(Port, (port) =>
NodeHttpServer.layer(() => createServer(), { port }),
).pipe(Effect.provide(Logger.minimumLogLevel(LogLevel.Debug))),
);

NodeRuntime.runMain(Layer.launch(Layer.provide(app, ServerLive)));
NodeRuntime.runMain(Layer.launch(AppLive));
8 changes: 8 additions & 0 deletions examples/minimal-expo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
"compilerOptions": {
"strict": true,
"jsx": "react-native",
/**
* Effect v4 only exposes its submodules (e.g. `effect/Schema`) through
* package.json `exports` (no `typesVersions` fallback), which the legacy
* `node` resolution from expo/tsconfig.base cannot follow. Metro resolves
* package exports since Expo SDK 53, so `bundler` matches runtime behavior.
*/
"module": "preserve",
"moduleResolution": "bundler",
"paths": {
"~/*": ["./*"]
}
Expand Down
4 changes: 2 additions & 2 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@
},
"dependencies": {
"@uploadthing/mime-types": "workspace:*",
"effect": "3.21.0",
"effect": "4.0.0-beta.93",
"sqids": "^0.3.0"
},
"devDependencies": {
"@effect/vitest": "0.29.0",
"@effect/vitest": "4.0.0-beta.93",
"@types/react": "19.2.7",
"@uploadthing/eslint-config": "workspace:",
"@uploadthing/tsconfig": "workspace:",
Expand Down
38 changes: 19 additions & 19 deletions packages/shared/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
import * as Hash from "effect/Hash";
import * as Micro from "effect/Micro";
import * as Redacted from "effect/Redacted";
import SQIds, { defaultOptions } from "sqids";

Expand Down Expand Up @@ -32,8 +32,8 @@ export const signPayload = (
payload: string,
secret: Redacted.Redacted<string>,
) =>
Micro.gen(function* () {
const signingKey = yield* Micro.tryPromise({
Effect.gen(function* () {
const signingKey = yield* Effect.tryPromise({
try: () =>
crypto.subtle.importKey(
"raw",
Expand All @@ -50,8 +50,8 @@ export const signPayload = (
}),
});

const signature = yield* Micro.map(
Micro.tryPromise({
const signature = yield* Effect.map(
Effect.tryPromise({
try: () =>
crypto.subtle.sign(algorithm, signingKey, encoder.encode(payload)),
catch: (e) => new UploadThingError({ code: "BAD_REQUEST", cause: e }),
Expand All @@ -60,30 +60,30 @@ export const signPayload = (
);

return `${signaturePrefix}${signature}`;
}).pipe(Micro.withTrace("signPayload"));
}).pipe(Effect.withSpan("signPayload"));

export const verifySignature = (
payload: string,
signature: string | null,
secret: Redacted.Redacted<string>,
) =>
Micro.gen(function* () {
Effect.gen(function* () {
const sig = signature?.slice(signaturePrefix.length);
if (!sig) return false;

const secretBytes = encoder.encode(Redacted.value(secret));
const signingKey = yield* Micro.promise(() =>
const signingKey = yield* Effect.promise(() =>
crypto.subtle.importKey("raw", secretBytes, algorithm, false, ["verify"]),
);

const sigBytes = yield* Micro.fromEither(Encoding.decodeHex(sig));
const sigBytes = yield* Effect.fromResult(Encoding.decodeHex(sig));
const payloadBytes = encoder.encode(payload);
return yield* Micro.promise(() =>
return yield* Effect.promise(() =>
crypto.subtle.verify(algorithm, signingKey, sigBytes, payloadBytes),
);
}).pipe(
Micro.withTrace("verifySignature"),
Micro.orElseSucceed(() => false),
Effect.withSpan("verifySignature"),
Effect.orElseSucceed(() => false),
);

export const generateKey = (
Expand All @@ -92,7 +92,7 @@ export const generateKey = (
getHashParts?: ExtractHashPartsFn,
hashFn?: HashFn,
) =>
Micro.sync(() => {
Effect.sync(() => {
// Get the parts of which we should hash to constuct the key
// This allows the user to customize the hashing algorithm
// If they for example want to generate the same key for the
Expand Down Expand Up @@ -120,20 +120,20 @@ export const generateKey = (

// Concatenate them
return encodedAppId + encodedFileSeed;
}).pipe(Micro.withTrace("generateKey"));
}).pipe(Effect.withSpan("generateKey"));

// Verify that the key was generated with the same appId
export const verifyKey = (key: string, appId: string) =>
Micro.sync(() => {
Effect.sync(() => {
const alphabet = shuffle(defaultOptions.alphabet, appId);
const expectedPrefix = new SQIds({ alphabet, minLength: 12 }).encode([
Math.abs(Hash.string(appId)),
]);

return key.startsWith(expectedPrefix);
}).pipe(
Micro.withTrace("verifyKey"),
Micro.orElseSucceed(() => false),
Effect.withSpan("verifyKey"),
Effect.orElseSucceed(() => false),
);

export const generateSignedURL = (
Expand All @@ -144,7 +144,7 @@ export const generateSignedURL = (
data?: Record<string, string | number | boolean | null | undefined>;
},
) =>
Micro.gen(function* () {
Effect.gen(function* () {
const parsedURL = new URL(url);

const ttl = opts.ttlInSeconds
Expand All @@ -166,4 +166,4 @@ export const generateSignedURL = (
parsedURL.searchParams.append("signature", signature);

return parsedURL.href;
}).pipe(Micro.withTrace("generateSignedURL"));
}).pipe(Effect.withSpan("generateSignedURL"));
33 changes: 16 additions & 17 deletions packages/shared/src/effect.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
import * as Context from "effect/Context";
import * as Micro from "effect/Micro";
import * as Effect from "effect/Effect";

import { BadRequestError, FetchError, InvalidJsonError } from "./tagged-errors";
import type { FetchEsque, ResponseEsque } from "./types";

export class FetchContext
extends /** #__PURE__ */ Context.Tag("uploadthing/Fetch")<
FetchContext,
FetchEsque
>() {}
extends /** #__PURE__ */ Context.Service<FetchContext, FetchEsque>()(
"uploadthing/Fetch",
) {}

interface ResponseWithURL extends ResponseEsque {
requestUrl: string;
}

// Temporary Effect wrappers below.
// Only for use in the browser.
// On the server, use `@effect/platform.HttpClient` instead.
// On the server, use `effect/unstable/http.HttpClient` instead.
export const fetchEff = (
input: string | URL,
init?: RequestInit,
): Micro.Micro<ResponseWithURL, FetchError, FetchContext> =>
Micro.flatMap(Micro.service(FetchContext), (fetch) => {
): Effect.Effect<ResponseWithURL, FetchError, FetchContext> =>
Effect.flatMap(FetchContext, (fetch) => {
const headers = new Headers(init?.headers ?? []);

const reqInfo = {
Expand All @@ -31,7 +30,7 @@ export const fetchEff = (
headers: Object.fromEntries(headers),
};

return Micro.tryPromise({
return Effect.tryPromise({
try: (signal) => fetch(input, { ...init, headers, signal }),
catch: (error) =>
new FetchError({
Expand All @@ -48,23 +47,23 @@ export const fetchEff = (
}),
}).pipe(
// eslint-disable-next-line no-console
Micro.tapError((e) => Micro.sync(() => console.error(e.input))),
Micro.map((res) => Object.assign(res, { requestUrl: reqInfo.url })),
Micro.withTrace("fetch"),
Effect.tapError((e) => Effect.sync(() => console.error(e.input))),
Effect.map((res) => Object.assign(res, { requestUrl: reqInfo.url })),
Effect.withSpan("fetch"),
);
});

export const parseResponseJson = (
res: ResponseWithURL,
): Micro.Micro<unknown, InvalidJsonError | BadRequestError> =>
Micro.tryPromise({
): Effect.Effect<unknown, InvalidJsonError | BadRequestError> =>
Effect.tryPromise({
try: async () => {
const json = await res.json();
return { json, ok: res.ok, status: res.status };
},
catch: (error) => new InvalidJsonError({ error, input: res.requestUrl }),
}).pipe(
Micro.filterOrFail(
Effect.filterOrFail(
({ ok }) => ok,
({ json, status }) =>
new BadRequestError({
Expand All @@ -73,6 +72,6 @@ export const parseResponseJson = (
json,
}),
),
Micro.map(({ json }) => json),
Micro.withTrace("parseJson"),
Effect.map(({ json }) => json),
Effect.withSpan("parseJson"),
);
6 changes: 3 additions & 3 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as Micro from "effect/Micro";
import * as Data from "effect/Data";
import * as Predicate from "effect/Predicate";

import type { Json } from "./types";
Expand Down Expand Up @@ -59,7 +59,7 @@ export interface SerializedUploadThingError {

export class UploadThingError<
TShape extends Json = { message: string },
> extends Micro.Error<{ message: string }> {
> extends Data.Error<{ message: string }> {
readonly _tag = "UploadThingError";
readonly name = "UploadThingError";

Expand All @@ -81,7 +81,7 @@ export class UploadThingError<
if (opts.cause instanceof Error) {
this.cause = opts.cause;
} else if (
Predicate.isRecord(opts.cause) &&
Predicate.isObject(opts.cause) &&
Predicate.isNumber(opts.cause.status) &&
Predicate.isString(opts.cause.statusText)
) {
Expand Down
Loading
Loading