anhedral 0.4.1 → 0.6.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/README.md +29 -256
- package/assets/anhedral-cli-init-technical.svg +322 -0
- package/assets/anhedral-cli-init.svg +257 -173
- package/dist/bin.js +28 -162
- package/dist/dependencies.d.ts +7 -56
- package/dist/dependencies.js +96 -305
- package/dist/index.d.ts +2 -7
- package/dist/index.js +1 -4
- package/dist/{templates → platforms}/desktop.d.ts +1 -1
- package/dist/{templates → platforms}/desktop.js +28 -266
- package/dist/{templates → platforms}/extension.d.ts +1 -1
- package/dist/platforms/extension.js +401 -0
- package/dist/{templates → platforms}/mobile.d.ts +1 -1
- package/dist/platforms/mobile.js +232 -0
- package/dist/project.d.ts +4 -0
- package/dist/project.js +1 -0
- package/dist/standard-products.d.ts +2 -0
- package/dist/standard-products.js +6 -0
- package/dist/standard.d.ts +18 -0
- package/dist/standard.js +578 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/application-stack-standard.md +700 -0
- package/package.json +34 -49
- package/CHANGELOG.md +0 -132
- package/CONTRIBUTING.md +0 -73
- package/SECURITY.md +0 -37
- package/SKILL.md +0 -244
- package/assets/images/svg/ably-symbol-color.svg +0 -8
- package/bin/anhedral.js +0 -2
- package/dist/architecture/contributions.d.ts +0 -28
- package/dist/architecture/contributions.js +0 -125
- package/dist/architecture/index.d.ts +0 -12
- package/dist/architecture/index.js +0 -6
- package/dist/architecture/manifest.d.ts +0 -53
- package/dist/architecture/manifest.js +0 -283
- package/dist/architecture/modules.d.ts +0 -37
- package/dist/architecture/modules.js +0 -184
- package/dist/architecture/plan.d.ts +0 -58
- package/dist/architecture/plan.js +0 -145
- package/dist/architecture/templates.d.ts +0 -10
- package/dist/architecture/templates.js +0 -23
- package/dist/cli.d.ts +0 -32
- package/dist/cli.js +0 -235
- package/dist/composers/root.d.ts +0 -6
- package/dist/composers/root.js +0 -35
- package/dist/print.d.ts +0 -8
- package/dist/print.js +0 -48
- package/dist/prompts.d.ts +0 -5
- package/dist/prompts.js +0 -19
- package/dist/scaffold.d.ts +0 -76
- package/dist/scaffold.js +0 -1895
- package/dist/template-source.d.ts +0 -20
- package/dist/template-source.js +0 -180
- package/dist/templates/api.d.ts +0 -2
- package/dist/templates/api.js +0 -3122
- package/dist/templates/assets-private-proxy.d.ts +0 -3
- package/dist/templates/assets-private-proxy.js +0 -277
- package/dist/templates/electron-updater.d.ts +0 -3
- package/dist/templates/electron-updater.js +0 -358
- package/dist/templates/extension.js +0 -732
- package/dist/templates/mobile.js +0 -720
- package/dist/templates/shared.d.ts +0 -2
- package/dist/templates/shared.js +0 -520
- package/dist/templates/web.d.ts +0 -2
- package/dist/templates/web.js +0 -412
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +0 -8
- package/dist/ui.d.ts +0 -33
- package/dist/ui.js +0 -199
- package/dist/workspace-config.d.ts +0 -11
- package/dist/workspace-config.js +0 -372
- package/docs/conventions.md +0 -92
- package/docs/master-stack-map.md +0 -199
- package/docs/output-tree-contract.md +0 -482
- package/docs/references/manual-scaffolding.md +0 -403
- package/docs/references/provisioning.md +0 -220
- package/scripts/render-master-stack-map.mjs +0 -510
- package/templates/api-fastify/apps/api/tsconfig.json +0 -20
- package/templates/catalog.json +0 -23
- package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
- package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
- package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
- package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
- package/templates/web-next/apps/web/next-env.d.ts +0 -6
package/dist/templates/shared.js
DELETED
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { writeFile } from '../util.js';
|
|
3
|
-
import { API_CLIENT_DEPENDENCIES, CONTRACTS_DEPENDENCIES, REALTIME_DEPENDENCIES, SHARED_DB_DEPENDENCIES, SHARED_PACKAGE_DEPENDENCIES, } from '../dependencies.js';
|
|
4
|
-
function writeTsConfig(root) {
|
|
5
|
-
writeFile(path.join(root, 'tsconfig.json'), JSON.stringify({
|
|
6
|
-
compilerOptions: {
|
|
7
|
-
target: 'ESNext',
|
|
8
|
-
module: 'ESNext',
|
|
9
|
-
moduleResolution: 'Bundler',
|
|
10
|
-
strict: true,
|
|
11
|
-
noUncheckedIndexedAccess: true,
|
|
12
|
-
skipLibCheck: true,
|
|
13
|
-
noEmit: true,
|
|
14
|
-
},
|
|
15
|
-
include: ['src/**/*'],
|
|
16
|
-
}, null, 2) + '\n');
|
|
17
|
-
}
|
|
18
|
-
function writeSharedDatabase(root, options) {
|
|
19
|
-
if (!options.features.database)
|
|
20
|
-
return;
|
|
21
|
-
const dir = path.join(root, 'packages/db');
|
|
22
|
-
writeFile(path.join(dir, 'package.json'), JSON.stringify({
|
|
23
|
-
name: '@shared/db',
|
|
24
|
-
version: '0.1.0',
|
|
25
|
-
private: true,
|
|
26
|
-
type: 'module',
|
|
27
|
-
exports: {
|
|
28
|
-
'.': './src/index.ts',
|
|
29
|
-
'./schema': './src/schema.ts',
|
|
30
|
-
'./app-schema': './src/app-schema.ts',
|
|
31
|
-
},
|
|
32
|
-
scripts: {
|
|
33
|
-
build: 'pnpm typecheck',
|
|
34
|
-
typecheck: 'tsc --noEmit',
|
|
35
|
-
'db:generate': 'drizzle-kit generate',
|
|
36
|
-
'db:migrate': 'tsx --env-file=.env src/migrate.ts',
|
|
37
|
-
'db:check': 'drizzle-kit check',
|
|
38
|
-
'db:studio': 'drizzle-kit studio',
|
|
39
|
-
},
|
|
40
|
-
dependencies: SHARED_DB_DEPENDENCIES.dependencies,
|
|
41
|
-
devDependencies: SHARED_DB_DEPENDENCIES.devDependencies,
|
|
42
|
-
}, null, 2) + '\n');
|
|
43
|
-
writeFile(path.join(dir, '.env.example'), 'DATABASE_URL=YOUR_NEON_POSTGRES_URL\n');
|
|
44
|
-
writeFile(path.join(dir, 'drizzle.config.ts'), `import 'dotenv/config';
|
|
45
|
-
import { defineConfig } from 'drizzle-kit';
|
|
46
|
-
|
|
47
|
-
export default defineConfig({
|
|
48
|
-
schema: ['./src/generated-schema.ts', './src/app-schema.ts'],
|
|
49
|
-
out: './migrations',
|
|
50
|
-
dialect: 'postgresql',
|
|
51
|
-
dbCredentials: { url: process.env.DATABASE_URL || '' },
|
|
52
|
-
});
|
|
53
|
-
`);
|
|
54
|
-
const storageTables = options.features.storage ? `
|
|
55
|
-
export const uploads = pgTable('uploads', {
|
|
56
|
-
id: text('id').primaryKey(),
|
|
57
|
-
// Clerk IDs are authoritative until an application intentionally adds a user projection.
|
|
58
|
-
userId: text('user_id').notNull(),
|
|
59
|
-
stagingObjectKey: text('staging_object_key').notNull().unique(),
|
|
60
|
-
objectKey: text('object_key').unique(),
|
|
61
|
-
contentType: text('content_type').notNull(),
|
|
62
|
-
expectedSize: integer('expected_size').notNull(),
|
|
63
|
-
actualSize: integer('actual_size'),
|
|
64
|
-
status: text('status').notNull().default('pending'),
|
|
65
|
-
rejectionReason: text('rejection_reason'),
|
|
66
|
-
uploadUrlExpiresAt: timestamp('upload_url_expires_at').notNull(),
|
|
67
|
-
stagingDeletedAt: timestamp('staging_deleted_at'),
|
|
68
|
-
confirmedAt: timestamp('confirmed_at'),
|
|
69
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
70
|
-
});
|
|
71
|
-
` : '';
|
|
72
|
-
const billingTables = options.features.billing ? `
|
|
73
|
-
export const subscriptions = pgTable('subscriptions', {
|
|
74
|
-
id: text('id').primaryKey(),
|
|
75
|
-
// RevenueCat App User IDs can be Clerk IDs before a local users row exists.
|
|
76
|
-
userId: text('user_id').notNull().unique(),
|
|
77
|
-
entitlement: text('entitlement').notNull().default('free'),
|
|
78
|
-
status: text('status').notNull().default('active'),
|
|
79
|
-
expiresAt: timestamp('expires_at'),
|
|
80
|
-
eventTimestamp: timestamp('event_timestamp').notNull(),
|
|
81
|
-
revision: integer('revision').notNull().default(1),
|
|
82
|
-
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
export const webhookEvents = pgTable('webhook_events', {
|
|
86
|
-
providerEventId: text('provider_event_id').primaryKey(),
|
|
87
|
-
provider: text('provider').notNull(),
|
|
88
|
-
payload: jsonb('payload').$type<Record<string, unknown>>().notNull(),
|
|
89
|
-
status: text('status').notNull().default('pending'),
|
|
90
|
-
attempts: integer('attempts').notNull().default(0),
|
|
91
|
-
claimToken: text('claim_token'),
|
|
92
|
-
claimedAt: timestamp('claimed_at'),
|
|
93
|
-
processedAt: timestamp('processed_at'),
|
|
94
|
-
lastError: text('last_error'),
|
|
95
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
export const realtimeOutbox = pgTable('realtime_outbox', {
|
|
99
|
-
id: text('id').primaryKey(),
|
|
100
|
-
userId: text('user_id').notNull(),
|
|
101
|
-
topic: text('topic').notNull(),
|
|
102
|
-
revision: integer('revision').notNull(),
|
|
103
|
-
attempts: integer('attempts').notNull().default(0),
|
|
104
|
-
deliveredAt: timestamp('delivered_at'),
|
|
105
|
-
lastError: text('last_error'),
|
|
106
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
107
|
-
});
|
|
108
|
-
` : '';
|
|
109
|
-
writeFile(path.join(dir, 'src/generated-schema.ts'), `import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
|
110
|
-
${storageTables}${billingTables}`);
|
|
111
|
-
writeFile(path.join(dir, 'src/app-schema.ts'), `import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
|
112
|
-
|
|
113
|
-
// Product-owned tables belong here. Anhedral never rewrites this file after creation.
|
|
114
|
-
export const items = pgTable('items', {
|
|
115
|
-
id: text('id').primaryKey(),
|
|
116
|
-
name: text('name').notNull(),
|
|
117
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
118
|
-
});
|
|
119
|
-
`);
|
|
120
|
-
writeFile(path.join(dir, 'src/schema.ts'), `export * from './generated-schema';
|
|
121
|
-
export * from './app-schema';
|
|
122
|
-
`);
|
|
123
|
-
writeFile(path.join(dir, 'src/index.ts'), `import { neon } from '@neondatabase/serverless';
|
|
124
|
-
import { drizzle } from 'drizzle-orm/neon-http';
|
|
125
|
-
import * as schema from './schema';
|
|
126
|
-
|
|
127
|
-
const databaseUrl = process.env.DATABASE_URL;
|
|
128
|
-
if (!databaseUrl) throw new Error('DATABASE_URL is required when the db module is enabled');
|
|
129
|
-
|
|
130
|
-
export const sqlClient = neon(databaseUrl);
|
|
131
|
-
export const db = drizzle(sqlClient, { schema });
|
|
132
|
-
export type Database = typeof db;
|
|
133
|
-
export * from './schema';
|
|
134
|
-
`);
|
|
135
|
-
writeFile(path.join(dir, 'src/migrate.ts'), `import { migrate } from 'drizzle-orm/neon-http/migrator';
|
|
136
|
-
import { db } from './index';
|
|
137
|
-
|
|
138
|
-
await migrate(db, { migrationsFolder: './migrations' });
|
|
139
|
-
console.log('Database migrations complete.');
|
|
140
|
-
`);
|
|
141
|
-
writeFile(path.join(dir, 'migrations/.gitkeep'), '');
|
|
142
|
-
}
|
|
143
|
-
function contractSource(options) {
|
|
144
|
-
const declarations = [
|
|
145
|
-
`export const HealthResponseSchema = z.object({ ok: z.literal(true), service: z.string() });
|
|
146
|
-
export type HealthResponse = z.infer<typeof HealthResponseSchema>;
|
|
147
|
-
export const ReadinessResponseSchema = z.discriminatedUnion('status', [
|
|
148
|
-
z.object({ ok: z.literal(true), service: z.string(), status: z.literal('ready') }),
|
|
149
|
-
z.object({
|
|
150
|
-
ok: z.literal(false),
|
|
151
|
-
service: z.string(),
|
|
152
|
-
status: z.enum(['unavailable', 'shutting_down']),
|
|
153
|
-
}),
|
|
154
|
-
]);
|
|
155
|
-
export type ReadinessResponse = z.infer<typeof ReadinessResponseSchema>;`,
|
|
156
|
-
options.features.auth ? `export const AuthMeResponseSchema = z.object({ user: z.object({ id: z.string() }) });
|
|
157
|
-
export type AuthMeResponse = z.infer<typeof AuthMeResponseSchema>;` : null,
|
|
158
|
-
options.features.billing ? `export const EntitlementResponseSchema = z.object({
|
|
159
|
-
entitlement: z.string(),
|
|
160
|
-
status: z.enum(['active', 'expired', 'free']),
|
|
161
|
-
expiresAt: z.string().datetime().nullable(),
|
|
162
|
-
revision: z.number().int().nonnegative(),
|
|
163
|
-
});
|
|
164
|
-
export type EntitlementResponse = z.infer<typeof EntitlementResponseSchema>;
|
|
165
|
-
|
|
166
|
-
export const SubscriptionChangedEventSchema = z.object({
|
|
167
|
-
type: z.literal('subscription.changed'),
|
|
168
|
-
revision: z.number().int().nonnegative(),
|
|
169
|
-
});
|
|
170
|
-
export type SubscriptionChangedEvent = z.infer<typeof SubscriptionChangedEventSchema>;
|
|
171
|
-
|
|
172
|
-
export const RealtimeTokenRequestSchema = z.object({
|
|
173
|
-
keyName: z.string().min(1),
|
|
174
|
-
ttl: z.number().int().positive(),
|
|
175
|
-
timestamp: z.number().int().nonnegative(),
|
|
176
|
-
capability: z.string().min(1),
|
|
177
|
-
clientId: z.string().min(1),
|
|
178
|
-
nonce: z.string().min(1),
|
|
179
|
-
mac: z.string().min(1),
|
|
180
|
-
});
|
|
181
|
-
export type RealtimeTokenRequest = z.infer<typeof RealtimeTokenRequestSchema>;` : null,
|
|
182
|
-
options.features.storage ? `export const UPLOAD_ALLOWED_CONTENT_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'] as const;
|
|
183
|
-
export const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024;
|
|
184
|
-
|
|
185
|
-
export const CreateUploadRequestSchema = z.object({
|
|
186
|
-
fileName: z.string().trim().min(1).max(200),
|
|
187
|
-
contentType: z.enum(UPLOAD_ALLOWED_CONTENT_TYPES),
|
|
188
|
-
sizeBytes: z.number().int().positive().max(MAX_UPLOAD_SIZE_BYTES),
|
|
189
|
-
}).strict();
|
|
190
|
-
export const CreateUploadResponseSchema = z.object({
|
|
191
|
-
uploadId: z.string().uuid(),
|
|
192
|
-
stagingObjectKey: z.string(),
|
|
193
|
-
uploadUrl: z.string().url(),
|
|
194
|
-
expiresIn: z.number().int().positive(),
|
|
195
|
-
requiredHeaders: z.object({ 'content-type': z.enum(UPLOAD_ALLOWED_CONTENT_TYPES) }),
|
|
196
|
-
sizeBytes: z.number().int().positive().max(MAX_UPLOAD_SIZE_BYTES),
|
|
197
|
-
signedContentLength: z.number().int().positive().max(MAX_UPLOAD_SIZE_BYTES),
|
|
198
|
-
metadataValidationRequired: z.literal(true),
|
|
199
|
-
});
|
|
200
|
-
export const ConfirmUploadRequestSchema = z.object({ uploadId: z.string().uuid() }).strict();
|
|
201
|
-
export const UploadParamsSchema = z.object({ uploadId: z.string().uuid() }).strict();
|
|
202
|
-
export const UploadRecordSchema = z.object({
|
|
203
|
-
id: z.string().uuid(),
|
|
204
|
-
objectKey: z.string(),
|
|
205
|
-
contentType: z.enum(UPLOAD_ALLOWED_CONTENT_TYPES),
|
|
206
|
-
sizeBytes: z.number().int().nonnegative(),
|
|
207
|
-
contentTrust: z.literal('untrusted'),
|
|
208
|
-
status: z.enum(['pending', 'confirmed', 'rejected']),
|
|
209
|
-
createdAt: z.string().datetime(),
|
|
210
|
-
confirmedAt: z.string().datetime().nullable(),
|
|
211
|
-
privateReadUrl: z.string().url(),
|
|
212
|
-
});
|
|
213
|
-
export const ConfirmUploadResponseSchema = z.object({ upload: UploadRecordSchema });
|
|
214
|
-
export const GetUploadResponseSchema = z.object({ upload: UploadRecordSchema });
|
|
215
|
-
export const GetUploadReadUrlResponseSchema = z.object({
|
|
216
|
-
url: z.string().url(),
|
|
217
|
-
expiresIn: z.number().int().min(60).max(604800),
|
|
218
|
-
});
|
|
219
|
-
export type CreateUploadRequest = z.infer<typeof CreateUploadRequestSchema>;
|
|
220
|
-
export type CreateUploadResponse = z.infer<typeof CreateUploadResponseSchema>;
|
|
221
|
-
export type ConfirmUploadRequest = z.infer<typeof ConfirmUploadRequestSchema>;
|
|
222
|
-
export type UploadRecord = z.infer<typeof UploadRecordSchema>;` : null,
|
|
223
|
-
].filter((value) => value !== null);
|
|
224
|
-
return `import { z } from 'zod';\n\n${declarations.join('\n\n')}\n`;
|
|
225
|
-
}
|
|
226
|
-
function apiClientSource(options) {
|
|
227
|
-
const imports = [
|
|
228
|
-
'HealthResponseSchema',
|
|
229
|
-
'ReadinessResponseSchema',
|
|
230
|
-
options.features.auth ? 'AuthMeResponseSchema' : null,
|
|
231
|
-
options.features.billing ? 'EntitlementResponseSchema' : null,
|
|
232
|
-
options.features.billing ? 'RealtimeTokenRequestSchema' : null,
|
|
233
|
-
options.features.storage ? 'CreateUploadResponseSchema' : null,
|
|
234
|
-
options.features.storage ? 'ConfirmUploadResponseSchema' : null,
|
|
235
|
-
options.features.storage ? 'GetUploadResponseSchema' : null,
|
|
236
|
-
options.features.storage ? 'GetUploadReadUrlResponseSchema' : null,
|
|
237
|
-
options.features.storage ? 'type CreateUploadRequest' : null,
|
|
238
|
-
options.features.storage ? 'type CreateUploadResponse' : null,
|
|
239
|
-
options.features.storage ? 'type ConfirmUploadRequest' : null,
|
|
240
|
-
].filter((value) => value !== null);
|
|
241
|
-
const methods = [
|
|
242
|
-
`health(init: RequestInit = {}) { return this.request('/health', init, HealthResponseSchema); }`,
|
|
243
|
-
`readiness(init: RequestInit = {}) { return this.request('/ready', init, ReadinessResponseSchema); }`,
|
|
244
|
-
options.features.auth ? `getMe(init: RequestInit = {}) { return this.request('/auth/me', init, AuthMeResponseSchema); }` : null,
|
|
245
|
-
options.features.billing ? `getEntitlement(init: RequestInit = {}) { return this.request('/subscriptions/me', init, EntitlementResponseSchema); }
|
|
246
|
-
|
|
247
|
-
refreshEntitlement(init: RequestInit = {}) {
|
|
248
|
-
return this.request('/subscriptions/refresh', { ...init, method: 'POST' }, EntitlementResponseSchema);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
getRealtimeToken(init: RequestInit = {}) {
|
|
252
|
-
return this.request('/realtime/token', { ...init, method: 'POST' }, RealtimeTokenRequestSchema);
|
|
253
|
-
}` : null,
|
|
254
|
-
options.features.storage ? `createUpload(input: CreateUploadRequest, init: RequestInit = {}) {
|
|
255
|
-
return this.request('/storage/uploads', { ...init, method: 'POST', body: JSON.stringify(input) }, CreateUploadResponseSchema);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async putUpload(upload: CreateUploadResponse, body: Blob, init: Omit<RequestInit, 'body' | 'method'> = {}) {
|
|
259
|
-
if (body.size !== upload.signedContentLength) {
|
|
260
|
-
throw new ApiError('Upload body size does not match the signed content length', {
|
|
261
|
-
status: 0,
|
|
262
|
-
code: 'UPLOAD_SIZE_MISMATCH',
|
|
263
|
-
details: { expected: upload.signedContentLength, actual: body.size },
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
const headers = new Headers(init.headers);
|
|
267
|
-
headers.set('content-type', upload.requiredHeaders['content-type']);
|
|
268
|
-
// Browsers forbid setting Content-Length directly. Blob/File bodies emit it automatically,
|
|
269
|
-
// and R2 verifies that runtime header because it is part of the presigned URL signature.
|
|
270
|
-
const response = await fetch(upload.uploadUrl, { ...init, method: 'PUT', body, headers });
|
|
271
|
-
if (!response.ok) {
|
|
272
|
-
throw new ApiError(\`Object upload failed with status \${response.status}\`, {
|
|
273
|
-
status: response.status,
|
|
274
|
-
code: 'OBJECT_UPLOAD_FAILED',
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
confirmUpload(input: ConfirmUploadRequest, init: RequestInit = {}) {
|
|
280
|
-
return this.request('/storage/uploads/confirm', { ...init, method: 'POST', body: JSON.stringify(input) }, ConfirmUploadResponseSchema);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
getUpload(uploadId: string, init: RequestInit = {}) {
|
|
284
|
-
return this.request(\`/storage/uploads/\${encodeURIComponent(uploadId)}\`, init, GetUploadResponseSchema);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
getUploadReadUrl(uploadId: string, init: RequestInit = {}) {
|
|
288
|
-
return this.request(\`/storage/uploads/\${encodeURIComponent(uploadId)}/read-url\`, init, GetUploadReadUrlResponseSchema);
|
|
289
|
-
}` : null,
|
|
290
|
-
].filter((value) => value !== null);
|
|
291
|
-
return `import type { ZodType } from 'zod';
|
|
292
|
-
import { ${imports.join(', ')} } from '@shared/contracts';
|
|
293
|
-
|
|
294
|
-
export function normalizeApiBaseUrl(value: string, label = 'API baseUrl'): string {
|
|
295
|
-
const candidate = value.trim();
|
|
296
|
-
let parsed: URL;
|
|
297
|
-
try {
|
|
298
|
-
parsed = new URL(candidate);
|
|
299
|
-
} catch {
|
|
300
|
-
throw new Error(\`\${label} must be a valid absolute URL\`);
|
|
301
|
-
}
|
|
302
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
303
|
-
throw new Error(\`\${label} must use http: or https:\`);
|
|
304
|
-
}
|
|
305
|
-
if (parsed.username || parsed.password) {
|
|
306
|
-
throw new Error(\`\${label} must not include URL credentials\`);
|
|
307
|
-
}
|
|
308
|
-
if (parsed.search || candidate.includes('?')) {
|
|
309
|
-
throw new Error(\`\${label} must not include a query string\`);
|
|
310
|
-
}
|
|
311
|
-
if (parsed.hash || candidate.includes('#')) {
|
|
312
|
-
throw new Error(\`\${label} must not include a URL fragment\`);
|
|
313
|
-
}
|
|
314
|
-
let pathname = parsed.pathname;
|
|
315
|
-
while (pathname.endsWith('/')) pathname = pathname.slice(0, -1);
|
|
316
|
-
return parsed.origin + pathname;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export class ApiError extends Error {
|
|
320
|
-
readonly status: number;
|
|
321
|
-
readonly code: string;
|
|
322
|
-
readonly details?: unknown;
|
|
323
|
-
|
|
324
|
-
constructor(message: string, options: { status: number; code: string; details?: unknown; cause?: unknown }) {
|
|
325
|
-
super(message, { cause: options.cause });
|
|
326
|
-
this.name = 'ApiError';
|
|
327
|
-
this.status = options.status;
|
|
328
|
-
this.code = options.code;
|
|
329
|
-
this.details = options.details;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
export class ApiClient {
|
|
334
|
-
private readonly baseUrl: string;
|
|
335
|
-
|
|
336
|
-
constructor(private readonly options: {
|
|
337
|
-
baseUrl: string;
|
|
338
|
-
getToken?: () => Promise<string | null>;
|
|
339
|
-
timeoutMs?: number;
|
|
340
|
-
}) {
|
|
341
|
-
this.baseUrl = normalizeApiBaseUrl(options.baseUrl);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async request<T>(path: string, init: RequestInit, schema: ZodType<T>): Promise<T> {
|
|
345
|
-
const timeoutMs = this.options.timeoutMs ?? 15_000;
|
|
346
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('API timeoutMs must be positive');
|
|
347
|
-
const requestController = new AbortController();
|
|
348
|
-
const callerSignal = init.signal;
|
|
349
|
-
const abortFromCaller = () => requestController.abort(callerSignal?.reason);
|
|
350
|
-
if (callerSignal?.aborted) abortFromCaller();
|
|
351
|
-
else callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
|
|
352
|
-
const timer = setTimeout(() => requestController.abort(new Error('API request timed out')), timeoutMs);
|
|
353
|
-
let rejectTokenWait: (() => void) | null = null;
|
|
354
|
-
const tokenWaitAborted = new Promise<never>((_resolve, reject) => {
|
|
355
|
-
rejectTokenWait = () => reject(requestController.signal.reason ?? new Error('API request aborted'));
|
|
356
|
-
if (requestController.signal.aborted) rejectTokenWait();
|
|
357
|
-
else requestController.signal.addEventListener('abort', rejectTokenWait, { once: true });
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
try {
|
|
361
|
-
const token = await Promise.race([
|
|
362
|
-
Promise.resolve(this.options.getToken?.()),
|
|
363
|
-
tokenWaitAborted,
|
|
364
|
-
]);
|
|
365
|
-
const hasBody = init.body !== undefined && init.body !== null;
|
|
366
|
-
const headers = new Headers(init.headers);
|
|
367
|
-
if (hasBody && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
|
368
|
-
if (token && !headers.has('authorization')) headers.set('authorization', \`Bearer \${token}\`);
|
|
369
|
-
const response = await fetch(this.baseUrl + path, {
|
|
370
|
-
...init,
|
|
371
|
-
signal: requestController.signal,
|
|
372
|
-
headers,
|
|
373
|
-
});
|
|
374
|
-
if (!response.ok) {
|
|
375
|
-
const payload = await response.json().catch(() => null) as {
|
|
376
|
-
error?: unknown;
|
|
377
|
-
message?: unknown;
|
|
378
|
-
details?: unknown;
|
|
379
|
-
} | null;
|
|
380
|
-
const code = typeof payload?.error === 'string' ? payload.error : 'HTTP_ERROR';
|
|
381
|
-
const message = typeof payload?.message === 'string'
|
|
382
|
-
? payload.message
|
|
383
|
-
: \`API request failed with status \${response.status}\`;
|
|
384
|
-
throw new ApiError(message, { status: response.status, code, details: payload?.details });
|
|
385
|
-
}
|
|
386
|
-
let body: unknown;
|
|
387
|
-
try {
|
|
388
|
-
body = await response.json();
|
|
389
|
-
} catch (cause) {
|
|
390
|
-
throw new ApiError('API returned invalid JSON', { status: response.status, code: 'INVALID_RESPONSE', cause });
|
|
391
|
-
}
|
|
392
|
-
const parsed = schema.safeParse(body);
|
|
393
|
-
if (!parsed.success) {
|
|
394
|
-
throw new ApiError('API response did not match its contract', {
|
|
395
|
-
status: response.status,
|
|
396
|
-
code: 'INVALID_RESPONSE',
|
|
397
|
-
details: parsed.error.flatten(),
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
return parsed.data;
|
|
401
|
-
} catch (cause) {
|
|
402
|
-
if (cause instanceof ApiError) throw cause;
|
|
403
|
-
if (requestController.signal.aborted) {
|
|
404
|
-
const callerAborted = Boolean(callerSignal?.aborted);
|
|
405
|
-
throw new ApiError(callerAborted ? 'API request was cancelled' : 'API request timed out', {
|
|
406
|
-
status: 0,
|
|
407
|
-
code: callerAborted ? 'REQUEST_ABORTED' : 'REQUEST_TIMEOUT',
|
|
408
|
-
cause,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
throw new ApiError('Unable to reach the API', { status: 0, code: 'NETWORK_ERROR', cause });
|
|
412
|
-
} finally {
|
|
413
|
-
clearTimeout(timer);
|
|
414
|
-
callerSignal?.removeEventListener('abort', abortFromCaller);
|
|
415
|
-
if (rejectTokenWait) requestController.signal.removeEventListener('abort', rejectTokenWait);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
${methods.join('\n\n ')}
|
|
420
|
-
}
|
|
421
|
-
`;
|
|
422
|
-
}
|
|
423
|
-
function writeApiPackages(root, options) {
|
|
424
|
-
if (!options.apps.api)
|
|
425
|
-
return;
|
|
426
|
-
const hasClientConsumer = options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension;
|
|
427
|
-
const packages = [
|
|
428
|
-
['contracts', { dependencies: CONTRACTS_DEPENDENCIES.dependencies }, contractSource(options)],
|
|
429
|
-
...(hasClientConsumer
|
|
430
|
-
? [['api-client', { dependencies: API_CLIENT_DEPENDENCIES.dependencies }, apiClientSource(options)]]
|
|
431
|
-
: []),
|
|
432
|
-
];
|
|
433
|
-
for (const [name, fields, source] of packages) {
|
|
434
|
-
const dir = path.join(root, `packages/${name}`);
|
|
435
|
-
writeFile(path.join(dir, 'package.json'), JSON.stringify({
|
|
436
|
-
name: `@shared/${name}`,
|
|
437
|
-
version: '0.1.0',
|
|
438
|
-
private: true,
|
|
439
|
-
type: 'module',
|
|
440
|
-
exports: { '.': './src/index.ts', './app': './src/app.ts' },
|
|
441
|
-
scripts: { build: 'pnpm typecheck', typecheck: 'tsc --noEmit' },
|
|
442
|
-
devDependencies: SHARED_PACKAGE_DEPENDENCIES.devDependencies,
|
|
443
|
-
...fields,
|
|
444
|
-
}, null, 2) + '\n');
|
|
445
|
-
writeTsConfig(dir);
|
|
446
|
-
writeFile(path.join(dir, 'src/generated.ts'), source);
|
|
447
|
-
writeFile(path.join(dir, 'src/app.ts'), `// Product-owned ${name === 'contracts' ? 'network contracts' : 'API client helpers'} belong here.
|
|
448
|
-
// Anhedral never rewrites this file after creation.
|
|
449
|
-
export {};
|
|
450
|
-
`);
|
|
451
|
-
writeFile(path.join(dir, 'src/index.ts'), `export * from './generated';
|
|
452
|
-
export * from './app';
|
|
453
|
-
`);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
function writeRealtimePackage(root, options) {
|
|
457
|
-
const hasClientConsumer = options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension;
|
|
458
|
-
if (!options.features.billing || !hasClientConsumer)
|
|
459
|
-
return;
|
|
460
|
-
const dir = path.join(root, 'packages/realtime');
|
|
461
|
-
writeFile(path.join(dir, 'package.json'), JSON.stringify({
|
|
462
|
-
name: '@shared/realtime',
|
|
463
|
-
version: '0.1.0',
|
|
464
|
-
private: true,
|
|
465
|
-
type: 'module',
|
|
466
|
-
exports: { '.': './src/index.ts' },
|
|
467
|
-
scripts: { build: 'pnpm typecheck', typecheck: 'tsc --noEmit' },
|
|
468
|
-
dependencies: REALTIME_DEPENDENCIES.dependencies,
|
|
469
|
-
devDependencies: REALTIME_DEPENDENCIES.devDependencies,
|
|
470
|
-
}, null, 2) + '\n');
|
|
471
|
-
writeTsConfig(dir);
|
|
472
|
-
writeFile(path.join(dir, 'src/index.ts'), `import * as Ably from 'ably';
|
|
473
|
-
import { SubscriptionChangedEventSchema, type RealtimeTokenRequest } from '@shared/contracts';
|
|
474
|
-
|
|
475
|
-
export type SubscriptionRealtimeOptions = {
|
|
476
|
-
userId: string;
|
|
477
|
-
getTokenRequest: () => Promise<RealtimeTokenRequest>;
|
|
478
|
-
onChange: (revision: number) => void;
|
|
479
|
-
onError?: (error: Error) => void;
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
export function subscriptionChannelName(userId: string): string {
|
|
483
|
-
return 'private:users:' + userId + ':subscriptions';
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export function subscribeToSubscriptionChanges(options: SubscriptionRealtimeOptions): () => void {
|
|
487
|
-
const client = new Ably.Realtime({
|
|
488
|
-
clientId: options.userId,
|
|
489
|
-
authCallback: (_params, callback) => {
|
|
490
|
-
void options.getTokenRequest().then(
|
|
491
|
-
(tokenRequest) => callback(null, tokenRequest),
|
|
492
|
-
(error: unknown) => callback(error instanceof Error ? error.message : 'Realtime authorization failed', null),
|
|
493
|
-
);
|
|
494
|
-
},
|
|
495
|
-
});
|
|
496
|
-
const channel = client.channels.get(subscriptionChannelName(options.userId));
|
|
497
|
-
const listener = (message: Ably.Message) => {
|
|
498
|
-
const parsed = SubscriptionChangedEventSchema.safeParse(message.data);
|
|
499
|
-
if (parsed.success) options.onChange(parsed.data.revision);
|
|
500
|
-
};
|
|
501
|
-
const stateListener = (change: Ably.ConnectionStateChange) => {
|
|
502
|
-
if (change.current === 'failed' && change.reason) options.onError?.(change.reason);
|
|
503
|
-
};
|
|
504
|
-
client.connection.on(stateListener);
|
|
505
|
-
void channel.subscribe('subscription.changed', listener).catch((error: unknown) => {
|
|
506
|
-
options.onError?.(error instanceof Error ? error : new Error('Realtime subscription failed'));
|
|
507
|
-
});
|
|
508
|
-
return () => {
|
|
509
|
-
channel.unsubscribe('subscription.changed', listener);
|
|
510
|
-
client.connection.off(stateListener);
|
|
511
|
-
client.close();
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
`);
|
|
515
|
-
}
|
|
516
|
-
export function scaffoldSharedPackages(root, options) {
|
|
517
|
-
writeSharedDatabase(root, options);
|
|
518
|
-
writeApiPackages(root, options);
|
|
519
|
-
writeRealtimePackage(root, options);
|
|
520
|
-
}
|
package/dist/templates/web.d.ts
DELETED