@clubedge/create-clubedge-app 0.6.0 → 0.7.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 (39) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +11 -11
  3. package/dist/cli/ui.js +1 -1
  4. package/dist/core/customize.js +5 -2
  5. package/dist/core/lockfile-peers.js +174 -0
  6. package/package.json +3 -3
  7. package/template/.env.example +4 -0
  8. package/template/README.md +7 -0
  9. package/template/SETUP.md +11 -0
  10. package/template/_gitignore +2 -0
  11. package/template/apps/start/.env.example +4 -0
  12. package/template/apps/start/package.json +1 -1
  13. package/template/apps/start/scripts/check-services.ts +22 -0
  14. package/template/apps/start/src/server/storage.ts +13 -0
  15. package/template/apps/web/package.json +1 -1
  16. package/template/apps/web/scripts/check-services.ts +22 -0
  17. package/template/apps/web/src/server/storage.ts +13 -0
  18. package/template/clubedge.template.json +19 -1
  19. package/template/package.json +1 -1
  20. package/template/packages/auth/package.json +1 -1
  21. package/template/packages/auth-better-auth/package.json +1 -1
  22. package/template/packages/auth-supabase/package.json +1 -1
  23. package/template/packages/cache/package.json +1 -1
  24. package/template/packages/cache-redis/package.json +1 -1
  25. package/template/packages/core/package.json +1 -1
  26. package/template/packages/db/package.json +1 -1
  27. package/template/packages/local-db/package.json +24 -0
  28. package/template/packages/local-db/scripts/serve.ts +66 -0
  29. package/template/packages/local-db/src/server.test.ts +119 -0
  30. package/template/packages/local-db/src/server.ts +153 -0
  31. package/template/packages/local-db/tsconfig.json +4 -0
  32. package/template/packages/storage/package.json +3 -2
  33. package/template/packages/storage/src/file-system.test.ts +110 -0
  34. package/template/packages/storage/src/file-system.ts +124 -0
  35. package/template/packages/storage-s3/package.json +1 -1
  36. package/template/packages/storage-supabase/package.json +1 -1
  37. package/template/packages/ui/package.json +1 -1
  38. package/template/pnpm-lock.yaml +31 -9
  39. package/template.lock.json +9 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache-redis",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Redis rate limiter and cache for @clubedge/cache.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/db",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@clubedge/local-db",
3
+ "version": "0.7.0",
4
+ "description": "PostgreSQL for development without Docker: PGlite served over the PostgreSQL protocol.",
5
+ "private": true,
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": "./src/server.ts"
11
+ },
12
+ "scripts": {
13
+ "dev": "tsx scripts/serve.ts",
14
+ "typecheck": "tsc --noEmit"
15
+ },
16
+ "dependencies": {
17
+ "@electric-sql/pglite": "0.5.8"
18
+ },
19
+ "devDependencies": {
20
+ "postgres": "^3.4.7",
21
+ "tsx": "^4.20.5",
22
+ "typescript": "^5.9.2"
23
+ }
24
+ }
@@ -0,0 +1,66 @@
1
+ // Runs PostgreSQL in this process with PGlite and serves it over the PostgreSQL protocol, so the
2
+ // app, migrations, and Drizzle Kit connect with an ordinary DATABASE_URL. For development only:
3
+ // PGlite has a single session, so clients take turns (see src/server.ts).
4
+ import { spawn } from "node:child_process";
5
+ import { mkdirSync } from "node:fs";
6
+ import { connect } from "node:net";
7
+ import { resolve } from "node:path";
8
+ import { PGlite } from "@electric-sql/pglite";
9
+ import { serveLocalPostgres } from "../src/server";
10
+
11
+ const host = "127.0.0.1";
12
+ const port = Number(process.env.LOCAL_DB_PORT ?? 5433);
13
+ const root = resolve(import.meta.dirname, "../../..");
14
+ const dataDir = resolve(root, ".data/pglite");
15
+ const url = `postgresql://postgres:postgres@${host}:${port}/postgres`;
16
+
17
+ /** Resolves true when something already accepts connections on the port. */
18
+ function isPortInUse(): Promise<boolean> {
19
+ return new Promise((done) => {
20
+ const socket = connect({ host, port });
21
+ socket.once("connect", () => {
22
+ socket.end();
23
+ done(true);
24
+ });
25
+ socket.once("error", () => done(false));
26
+ });
27
+ }
28
+
29
+ /** Applies every package's migrations, like `pnpm db:migrate`, against this server. */
30
+ function migrate(): Promise<boolean> {
31
+ return new Promise((done) => {
32
+ const child = spawn("pnpm", ["run", "db:migrate"], {
33
+ cwd: root,
34
+ env: { ...process.env, DATABASE_URL: url },
35
+ shell: process.platform === "win32",
36
+ stdio: "inherit",
37
+ });
38
+ child.once("error", () => done(false));
39
+ child.once("exit", (code) => done(code === 0));
40
+ });
41
+ }
42
+
43
+ // Check the port before opening the data directory: two processes must never open it at once.
44
+ if (await isPortInUse()) {
45
+ console.info(`Port ${port} is in use; the local database is probably already running.`);
46
+ process.exit(0);
47
+ }
48
+
49
+ mkdirSync(dataDir, { recursive: true });
50
+ const db = await PGlite.create(dataDir);
51
+ const server = await serveLocalPostgres(db, { host, port });
52
+ console.info(`Local PostgreSQL (PGlite) on ${url}, data in .data/pglite`);
53
+
54
+ let stopping = false;
55
+ async function stop() {
56
+ if (stopping) return;
57
+ stopping = true;
58
+ // Close PGlite cleanly so its data directory stays consistent.
59
+ await server.close();
60
+ await db.close();
61
+ process.exit(0);
62
+ }
63
+ for (const signal of ["SIGINT", "SIGTERM", "SIGBREAK"] as const) process.on(signal, stop);
64
+
65
+ if (await migrate()) console.info("Local database ready.");
66
+ else console.error("Migrations failed; the database is still running.");
@@ -0,0 +1,119 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import postgres from "postgres";
3
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
4
+ import { serveLocalPostgres, type LocalPostgresServer } from "./server";
5
+
6
+ describe("serveLocalPostgres", () => {
7
+ let db: PGlite;
8
+ let server: LocalPostgresServer;
9
+ let url: string;
10
+
11
+ beforeAll(async () => {
12
+ db = await PGlite.create();
13
+ server = await serveLocalPostgres(db, { port: 0 });
14
+ url = `postgresql://postgres:postgres@127.0.0.1:${server.port}/postgres`;
15
+ });
16
+
17
+ afterAll(async () => {
18
+ await server.close();
19
+ await db.close();
20
+ });
21
+
22
+ it("answers queries from several clients at once", async () => {
23
+ // Prepared statements off, as packages/db configures postgres.js.
24
+ const clients = [1, 2, 3].map(() => postgres(url, { max: 4, prepare: false }));
25
+ try {
26
+ // Statements with and without parameters, so one client's statement can never stand in
27
+ // for another's.
28
+ const results = await Promise.all(
29
+ Array.from({ length: 60 }, (_, i) =>
30
+ i % 2
31
+ ? clients[i % 3]!`select ${i}::int as n, ${`value ${i}`}::text as label`
32
+ : clients[i % 3]!`select 0 as n, 'constant' as label`,
33
+ ),
34
+ );
35
+ results.forEach(([row], i) =>
36
+ expect(row).toEqual(i % 2 ? { n: i, label: `value ${i}` } : { n: 0, label: "constant" }),
37
+ );
38
+ } finally {
39
+ await Promise.all(clients.map((client) => client.end()));
40
+ }
41
+ });
42
+
43
+ it("keeps other clients out of an open transaction", async () => {
44
+ const a = postgres(url, { max: 4, prepare: false });
45
+ const b = postgres(url, { max: 4, prepare: false });
46
+ try {
47
+ await a`create table entries (source text not null)`;
48
+ const work: Promise<unknown>[] = [];
49
+ for (let i = 0; i < 12; i++) {
50
+ work.push(
51
+ a
52
+ .begin(async (sql) => {
53
+ await sql`insert into entries values ('transaction')`;
54
+ await sql`select pg_sleep(0.005)`;
55
+ if (i % 3 === 0) throw new Error("roll back");
56
+ })
57
+ .catch(() => undefined),
58
+ );
59
+ work.push(b`insert into entries values ('outside')`);
60
+ }
61
+ await Promise.all(work);
62
+
63
+ const rows = await a<{ source: string; count: number }[]>`
64
+ select source, count(*)::int as count from entries group by source order by source`;
65
+ // Rolled-back transactions take only their own inserts with them.
66
+ expect(rows).toEqual([
67
+ { source: "outside", count: 12 },
68
+ { source: "transaction", count: 8 },
69
+ ]);
70
+ } finally {
71
+ await a.end();
72
+ await b.end();
73
+ }
74
+ });
75
+
76
+ it("rolls back a transaction left open by a client that disconnects", async () => {
77
+ const a = postgres(url, { max: 1, prepare: false });
78
+ await a`create table drafts (id int)`;
79
+ const reserved = await a.reserve();
80
+ await reserved`begin`;
81
+ await reserved`insert into drafts values (1)`;
82
+ await a.end({ timeout: 0 });
83
+
84
+ const b = postgres(url, { max: 1, prepare: false });
85
+ try {
86
+ expect(await b`select count(*)::int as count from drafts`).toEqual([{ count: 0 }]);
87
+ } finally {
88
+ await b.end();
89
+ }
90
+ });
91
+ });
92
+
93
+ describe("LocalPostgresServer.close", () => {
94
+ it("finishes the rollback of open transactions before it resolves", async () => {
95
+ const db = await PGlite.create();
96
+ const server = await serveLocalPostgres(db, { port: 0 });
97
+ const client = postgres(`postgresql://postgres:postgres@127.0.0.1:${server.port}/postgres`, {
98
+ max: 1,
99
+ prepare: false,
100
+ });
101
+ try {
102
+ const reserved = await client.reserve();
103
+ await reserved`begin`;
104
+ await reserved`create table pending (id int)`;
105
+ expect(db.isInTransaction()).toBe(true);
106
+
107
+ await server.close();
108
+
109
+ // The caller can close PGlite right away: nothing is left running on it.
110
+ expect(db.isInTransaction()).toBe(false);
111
+ expect((await db.query("select to_regclass('pending') as name")).rows).toEqual([
112
+ { name: null },
113
+ ]);
114
+ } finally {
115
+ await client.end({ timeout: 0 });
116
+ await db.close();
117
+ }
118
+ });
119
+ });
@@ -0,0 +1,153 @@
1
+ import { createServer, type Server, type Socket } from "node:net";
2
+ import type { PGlite } from "@electric-sql/pglite";
3
+
4
+ // Request codes sent instead of a startup message (PostgreSQL protocol, "Message Formats").
5
+ const SSL_REQUEST = 80877103;
6
+ const GSSENC_REQUEST = 80877104;
7
+ const CANCEL_REQUEST = 80877102;
8
+ // Messages that send a request off: Sync, simple Query, Flush, Terminate, CopyDone, CopyFail.
9
+ const REQUEST_END = new Set(["S", "Q", "H", "X", "c", "f"].map((type) => type.charCodeAt(0)));
10
+ const FLUSH = "H".charCodeAt(0);
11
+
12
+ export interface LocalPostgresServer {
13
+ port: number;
14
+ close(): Promise<void>;
15
+ }
16
+
17
+ /**
18
+ * Serves a PGlite database over the PostgreSQL protocol so ordinary clients (postgres.js, Drizzle
19
+ * Kit, psql) can share it. PGlite has a single session, so clients take turns: a client keeps the
20
+ * session from its first message until its Sync or simple Query, including across Flush (clients
21
+ * such as postgres.js describe a statement, then bind it), and for as long as its transaction is
22
+ * open. Other clients wait meanwhile.
23
+ */
24
+ export async function serveLocalPostgres(
25
+ db: PGlite,
26
+ { host = "127.0.0.1", port = 5433 }: { host?: string; port?: number } = {},
27
+ ): Promise<LocalPostgresServer> {
28
+ await db.waitReady;
29
+
30
+ // The connection that may use the session, and the connections waiting for it.
31
+ let owner: Socket | null = null;
32
+ const waiting: Array<() => void> = [];
33
+
34
+ async function acquire(socket: Socket) {
35
+ while (owner && owner !== socket) await new Promise<void>((wake) => waiting.push(wake));
36
+ owner = socket;
37
+ }
38
+
39
+ function release(socket: Socket) {
40
+ if (owner !== socket) return;
41
+ owner = null;
42
+ for (const wake of waiting.splice(0)) wake();
43
+ }
44
+
45
+ async function run(socket: Socket, messages: Uint8Array[]) {
46
+ // Requests still queued when their client disconnected are dropped.
47
+ if (socket.destroyed) return;
48
+ await acquire(socket);
49
+ try {
50
+ for (const message of messages) {
51
+ await db.execProtocolRawStream(message, {
52
+ onRawData: (data) => {
53
+ if (socket.writable) socket.write(data);
54
+ },
55
+ });
56
+ }
57
+ } finally {
58
+ // After a Flush the client continues the same request, so it keeps the session.
59
+ const flushed = messages.at(-1)?.[0] === FLUSH;
60
+ if (!flushed && !db.isInTransaction()) release(socket);
61
+ }
62
+ }
63
+
64
+ async function disconnect(socket: Socket) {
65
+ if (owner !== socket) return;
66
+ if (db.isInTransaction()) await db.exec("ROLLBACK");
67
+ release(socket);
68
+ }
69
+
70
+ const sockets = new Set<Socket>();
71
+ // Settles once a connection's queued work, including its disconnect cleanup, has finished.
72
+ const connections = new Set<Promise<void>>();
73
+ const server: Server = createServer((socket) => {
74
+ sockets.add(socket);
75
+ socket.setNoDelay(true);
76
+ let buffer = Buffer.alloc(0);
77
+ let started = false;
78
+ let request: Uint8Array[] = [];
79
+ // Requests from one connection run in order.
80
+ let queue = Promise.resolve();
81
+
82
+ function enqueue(messages: Uint8Array[]) {
83
+ queue = queue.then(() => run(socket, messages)).catch(() => void socket.destroy());
84
+ }
85
+
86
+ socket.on("data", (chunk) => {
87
+ buffer = Buffer.concat([buffer, chunk]);
88
+ while (true) {
89
+ if (!started) {
90
+ // Startup-phase messages have no type byte: a length, then a protocol or request code.
91
+ if (buffer.length < 8) return;
92
+ const length = buffer.readInt32BE(0);
93
+ if (buffer.length < length) return;
94
+ const code = buffer.readInt32BE(4);
95
+ const message = buffer.subarray(0, length);
96
+ buffer = buffer.subarray(length);
97
+ if (code === SSL_REQUEST || code === GSSENC_REQUEST) {
98
+ socket.write("N"); // Encryption is not supported; the client continues in plain text.
99
+ } else if (code === CANCEL_REQUEST) {
100
+ socket.end(); // Cancellation is not supported.
101
+ return;
102
+ } else {
103
+ started = true;
104
+ enqueue([new Uint8Array(message)]);
105
+ }
106
+ continue;
107
+ }
108
+ if (buffer.length < 5) return;
109
+ const length = 1 + buffer.readInt32BE(1);
110
+ if (buffer.length < length) return;
111
+ const type = buffer[0]!;
112
+ request.push(new Uint8Array(buffer.subarray(0, length)));
113
+ buffer = buffer.subarray(length);
114
+ if (REQUEST_END.has(type)) {
115
+ enqueue(request);
116
+ request = [];
117
+ }
118
+ if (type === "X".charCodeAt(0)) {
119
+ queue = queue.then(() => void socket.end());
120
+ return;
121
+ }
122
+ }
123
+ });
124
+
125
+ const finished = new Promise<void>((resolve) => {
126
+ socket.on("close", () => {
127
+ sockets.delete(socket);
128
+ queue = queue.then(() => disconnect(socket)).catch(() => undefined);
129
+ void queue.then(resolve);
130
+ });
131
+ });
132
+ connections.add(finished);
133
+ void finished.then(() => connections.delete(finished));
134
+ socket.on("error", () => socket.destroy());
135
+ });
136
+
137
+ await new Promise<void>((resolve, reject) => {
138
+ server.once("error", reject);
139
+ server.listen(port, host, () => resolve());
140
+ });
141
+ const address = server.address();
142
+
143
+ return {
144
+ port: typeof address === "object" && address ? address.port : port,
145
+ async close() {
146
+ const closed = new Promise<void>((resolve) => server.close(() => resolve()));
147
+ for (const socket of sockets) socket.destroy();
148
+ await closed;
149
+ // Let queued work and rollbacks finish, so the caller can then close PGlite safely.
150
+ await Promise.all(connections);
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*.ts", "scripts/**/*.ts"]
4
+ }
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@clubedge/storage",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": "./src/index.ts",
10
+ "./file-system": "./src/file-system.ts"
10
11
  },
11
12
  "scripts": {
12
13
  "typecheck": "tsc --noEmit"
@@ -0,0 +1,110 @@
1
+ import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { createFileSystemStorage } from "./file-system";
6
+
7
+ describe("createFileSystemStorage", () => {
8
+ let directory: string;
9
+
10
+ beforeEach(async () => {
11
+ directory = await mkdtemp(join(tmpdir(), "clubedge-storage-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await rm(directory, { recursive: true, force: true });
16
+ });
17
+
18
+ it("uploads, reads back, and deletes objects in nested folders", async () => {
19
+ const storage = createFileSystemStorage({ directory });
20
+ await storage.upload({
21
+ key: "avatars/user-1.txt",
22
+ body: new TextEncoder().encode("hello"),
23
+ contentType: "text/plain",
24
+ });
25
+
26
+ expect(await readFile(join(directory, "avatars", "user-1.txt"), "utf8")).toBe("hello");
27
+ expect(await storage.exists("avatars/user-1.txt")).toBe(true);
28
+ expect(await new Response(await storage.download("avatars/user-1.txt")).text()).toBe("hello");
29
+
30
+ await storage.delete("avatars/user-1.txt");
31
+ expect(await storage.exists("avatars/user-1.txt")).toBe(false);
32
+ });
33
+
34
+ it("accepts blobs and streams as bodies", async () => {
35
+ const storage = createFileSystemStorage({ directory });
36
+ await storage.upload({
37
+ key: "blob.txt",
38
+ body: new Blob(["from a blob"]),
39
+ contentType: "text/plain",
40
+ });
41
+ await storage.upload({
42
+ key: "stream.txt",
43
+ body: new Blob(["from a stream"]).stream(),
44
+ contentType: "text/plain",
45
+ });
46
+
47
+ expect(await readFile(join(directory, "blob.txt"), "utf8")).toBe("from a blob");
48
+ expect(await readFile(join(directory, "stream.txt"), "utf8")).toBe("from a stream");
49
+ });
50
+
51
+ it("rejects keys that would leave the folder", async () => {
52
+ const storage = createFileSystemStorage({ directory });
53
+ const body = new Uint8Array();
54
+ for (const key of ["../outside.txt", "a/../../outside.txt", "", join(directory, "..", "x")]) {
55
+ await expect(storage.upload({ key, body, contentType: "text/plain" })).rejects.toThrow(
56
+ "Invalid storage key",
57
+ );
58
+ }
59
+ });
60
+
61
+ it("leaves no file behind when a streamed upload fails", async () => {
62
+ const storage = createFileSystemStorage({ directory });
63
+ const failing = new ReadableStream<Uint8Array>({
64
+ start(controller) {
65
+ controller.enqueue(new TextEncoder().encode("partial"));
66
+ controller.error(new Error("connection lost"));
67
+ },
68
+ });
69
+
70
+ await expect(
71
+ storage.upload({ key: "uploads/broken.txt", body: failing, contentType: "text/plain" }),
72
+ ).rejects.toThrow("connection lost");
73
+ expect(await readdir(join(directory, "uploads"))).toEqual([]);
74
+ });
75
+
76
+ it("rejects keys that leave the folder through a symbolic link", async () => {
77
+ const outside = await mkdtemp(join(tmpdir(), "clubedge-outside-"));
78
+ try {
79
+ await writeFile(join(outside, "secret.txt"), "secret");
80
+ await mkdir(directory, { recursive: true });
81
+ // A junction on Windows needs no extra privileges; other systems ignore the type.
82
+ await symlink(outside, join(directory, "linked"), "junction");
83
+ const storage = createFileSystemStorage({ directory });
84
+ const body = new TextEncoder().encode("x");
85
+
86
+ await expect(
87
+ storage.upload({ key: "linked/new.txt", body, contentType: "text/plain" }),
88
+ ).rejects.toThrow("Invalid storage key");
89
+ await expect(storage.download("linked/secret.txt")).rejects.toThrow("Invalid storage key");
90
+ await expect(storage.delete("linked/secret.txt")).rejects.toThrow("Invalid storage key");
91
+ expect(await readFile(join(outside, "secret.txt"), "utf8")).toBe("secret");
92
+ expect(await readdir(outside)).toEqual(["secret.txt"]);
93
+ } finally {
94
+ await rm(outside, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ it("treats objects as missing before the folder exists", async () => {
99
+ const storage = createFileSystemStorage({ directory: join(directory, "not-created") });
100
+ expect(await storage.exists("a.txt")).toBe(false);
101
+ await expect(storage.delete("a.txt")).resolves.toBeUndefined();
102
+ await expect(storage.download("a.txt")).rejects.toThrow("Object not found");
103
+ });
104
+
105
+ it("fails to download a missing object and deletes missing objects quietly", async () => {
106
+ const storage = createFileSystemStorage({ directory });
107
+ await expect(storage.download("missing.txt")).rejects.toThrow();
108
+ await expect(storage.delete("missing.txt")).resolves.toBeUndefined();
109
+ });
110
+ });
@@ -0,0 +1,124 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { access, lstat, mkdir, realpath, rename, rm } from "node:fs/promises";
4
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
8
+ import type { StorageProvider, UploadInput } from "./types";
9
+
10
+ export interface FileSystemStorageConfig {
11
+ /** Folder that holds the objects; created on first upload. */
12
+ directory: string;
13
+ }
14
+
15
+ function bodyStream(body: UploadInput["body"]): Readable {
16
+ if (body instanceof Uint8Array) return Readable.from([body]);
17
+ if (body instanceof ArrayBuffer) return Readable.from([new Uint8Array(body)]);
18
+ const stream = body instanceof Blob ? body.stream() : body;
19
+ return Readable.fromWeb(stream as NodeReadableStream<Uint8Array>);
20
+ }
21
+
22
+ function isInside(root: string, path: string) {
23
+ const inside = relative(root, path);
24
+ return inside !== "" && !inside.startsWith("..") && !isAbsolute(inside);
25
+ }
26
+
27
+ function isMissing(error: unknown) {
28
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
29
+ }
30
+
31
+ /**
32
+ * Stores objects as files in a local folder, for development without an object storage service.
33
+ * Keys map to paths inside the folder. Keys that would leave it are rejected, including through
34
+ * symbolic links inside the folder.
35
+ */
36
+ export function createFileSystemStorage({ directory }: FileSystemStorageConfig): StorageProvider {
37
+ const root = resolve(directory);
38
+
39
+ function lexicalPath(key: string) {
40
+ const path = resolve(root, key);
41
+ if (!key || !isInside(root, path)) throw new Error(`Invalid storage key: ${key}`);
42
+ return path;
43
+ }
44
+
45
+ /** Throws unless the deepest existing folder on the way to `path` is inside the root. */
46
+ async function assertNoLinkEscape(path: string) {
47
+ const realRoot = await realpath(root);
48
+ let folder = dirname(path);
49
+ while (true) {
50
+ try {
51
+ const realFolder = await realpath(folder);
52
+ if (realFolder !== realRoot && !isInside(realRoot, realFolder)) {
53
+ throw new Error(`Invalid storage key: ${relative(root, path)}`);
54
+ }
55
+ break;
56
+ } catch (error) {
57
+ if (!isMissing(error)) throw error;
58
+ folder = dirname(folder);
59
+ }
60
+ }
61
+ try {
62
+ if ((await lstat(path)).isSymbolicLink()) {
63
+ throw new Error(`Invalid storage key: ${relative(root, path)}`);
64
+ }
65
+ } catch (error) {
66
+ if (!isMissing(error)) throw error;
67
+ }
68
+ }
69
+
70
+ /** The file for `key`, checked against the root. Resolves null when the root does not exist. */
71
+ async function existingPath(key: string) {
72
+ const path = lexicalPath(key);
73
+ try {
74
+ await assertNoLinkEscape(path);
75
+ } catch (error) {
76
+ if (isMissing(error)) return null;
77
+ throw error;
78
+ }
79
+ return path;
80
+ }
81
+
82
+ return {
83
+ async upload({ key, body }) {
84
+ const path = lexicalPath(key);
85
+ await mkdir(root, { recursive: true });
86
+ await assertNoLinkEscape(path);
87
+ await mkdir(dirname(path), { recursive: true });
88
+ await assertNoLinkEscape(path);
89
+ // Stream into a temporary file, then rename it, so a failed upload leaves nothing behind.
90
+ const partial = `${path}.${randomUUID()}.partial`;
91
+ try {
92
+ await pipeline(bodyStream(body), createWriteStream(partial, { flags: "wx" }));
93
+ await rename(partial, path);
94
+ } catch (error) {
95
+ await rm(partial, { force: true });
96
+ throw error;
97
+ }
98
+ return { key };
99
+ },
100
+ async download(key) {
101
+ const path = await existingPath(key);
102
+ if (!path) throw new Error(`Object not found: ${key}`);
103
+ await access(path);
104
+ return Readable.toWeb(createReadStream(path)) as ReadableStream<Uint8Array>;
105
+ },
106
+ async delete(key) {
107
+ const path = await existingPath(key);
108
+ if (path) await rm(path, { force: true });
109
+ },
110
+ async exists(key) {
111
+ const path = await existingPath(key);
112
+ if (!path) return false;
113
+ try {
114
+ await access(path);
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ },
120
+ async getSignedUrl() {
121
+ throw new Error("Local folder storage cannot create URLs; serve the files through your app.");
122
+ },
123
+ };
124
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-s3",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "S3-compatible adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-supabase",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Supabase Storage adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/ui",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",