@ericminassian/plat 0.1.0 → 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 (52) hide show
  1. package/dist/build.d.ts +4 -1
  2. package/dist/build.js +4 -7
  3. package/dist/build.js.map +1 -1
  4. package/dist/commands/common.d.ts +2 -2
  5. package/dist/commands/common.js +2 -2
  6. package/dist/commands/common.js.map +1 -1
  7. package/dist/commands/lifecycle.js +4 -3
  8. package/dist/commands/lifecycle.js.map +1 -1
  9. package/dist/commands/manage.js +8 -8
  10. package/dist/commands/manage.js.map +1 -1
  11. package/dist/commands/observe.js +5 -5
  12. package/dist/commands/observe.js.map +1 -1
  13. package/dist/deploy.js +2 -2
  14. package/dist/deploy.js.map +1 -1
  15. package/dist/dev/crons.d.ts +1 -1
  16. package/dist/dev/index.js +1 -1
  17. package/dist/dev/index.js.map +1 -1
  18. package/dist/dev/local-stage.js +1 -1
  19. package/dist/dev/router.js +1 -1
  20. package/dist/dev/router.js.map +1 -1
  21. package/dist/dev/test.js +1 -1
  22. package/dist/dev/test.js.map +1 -1
  23. package/dist/init.d.ts +7 -0
  24. package/dist/init.js +15 -3
  25. package/dist/init.js.map +1 -1
  26. package/dist/project.d.ts +6 -7
  27. package/dist/project.js +78 -21
  28. package/dist/project.js.map +1 -1
  29. package/dist/util/esbuild.d.ts +2 -0
  30. package/dist/util/esbuild.js +9 -0
  31. package/dist/util/esbuild.js.map +1 -0
  32. package/package.json +4 -4
  33. package/src/build.ts +7 -10
  34. package/src/commands/common.ts +3 -3
  35. package/src/commands/lifecycle.ts +4 -3
  36. package/src/commands/manage.ts +8 -8
  37. package/src/commands/observe.ts +5 -5
  38. package/src/deploy.ts +2 -2
  39. package/src/dev/crons.ts +1 -1
  40. package/src/dev/index.ts +1 -1
  41. package/src/dev/local-stage.ts +1 -1
  42. package/src/dev/router.ts +1 -1
  43. package/src/dev/test.ts +1 -1
  44. package/src/init.ts +16 -3
  45. package/src/project.ts +76 -22
  46. package/src/util/esbuild.ts +10 -0
  47. package/template/AGENTS.md +23 -18
  48. package/template/plat.config.ts +31 -0
  49. package/template/src/server.ts +3 -3
  50. package/template/src/worker.ts +5 -5
  51. package/template/tsconfig.json +1 -1
  52. package/template/platform.json +0 -18
package/src/project.ts CHANGED
@@ -1,47 +1,64 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname, join, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { ManifestError, parseManifest, type ResolvedManifest } from '@ericminassian/platform-core';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import {
6
+ APP_CONFIG_FILE,
7
+ ManifestError,
8
+ parseManifest,
9
+ type ResolvedManifest,
10
+ } from '@ericminassian/platform-core';
11
+ import { build as esbuild } from 'esbuild';
12
+ import { buildFailureList } from './util/esbuild.ts';
6
13
  import { CliError, EXIT } from './util/output.ts';
7
14
 
8
- export const MANIFEST_FILE = 'platform.json';
15
+ /** Apps configured with JSON before `plat.config.ts`; only detected to say how to move. */
16
+ const LEGACY_MANIFEST_FILE = 'platform.json';
9
17
 
10
18
  export interface Project {
11
- /** Directory containing platform.json. */
19
+ /** Directory containing plat.config.ts. */
12
20
  root: string;
13
- /** platform.json as written. */
21
+ /** The config's default export as JSON: exactly what is validated, built and deployed. */
14
22
  raw: Record<string, unknown>;
15
23
  manifest: ResolvedManifest;
16
24
  }
17
25
 
18
- /** Walk up from `cwd` to the nearest platform.json. */
19
- export function findProjectRoot(cwd: string): string | null {
26
+ /** Walk up from `cwd` to the nearest directory containing `name`. */
27
+ function findUp(cwd: string, name: string): string | null {
20
28
  let dir = resolve(cwd);
21
29
  for (;;) {
22
- if (existsSync(join(dir, MANIFEST_FILE))) return dir;
30
+ if (existsSync(join(dir, name))) return dir;
23
31
  const parent = dirname(dir);
24
32
  if (parent === dir) return null;
25
33
  dir = parent;
26
34
  }
27
35
  }
28
36
 
29
- export function loadProject(cwd: string): Project {
37
+ /** Walk up from `cwd` to the nearest plat.config.ts. */
38
+ export function findProjectRoot(cwd: string): string | null {
39
+ return findUp(cwd, APP_CONFIG_FILE);
40
+ }
41
+
42
+ export async function loadProject(cwd: string): Promise<Project> {
30
43
  const root = findProjectRoot(cwd);
31
44
  if (!root) {
32
- throw new CliError(`no ${MANIFEST_FILE} found in ${cwd} or its parents`, {
33
- hint: 'run this inside an app, or create one with `plat init <dir> --name <app>`',
45
+ const legacy = findUp(cwd, LEGACY_MANIFEST_FILE);
46
+ throw new CliError(`no ${APP_CONFIG_FILE} found in ${cwd} or its parents`, {
47
+ hint: legacy
48
+ ? `${join(legacy, LEGACY_MANIFEST_FILE)} is no longer read: move its fields into ${APP_CONFIG_FILE} as \`export default defineApp({ ... })\` from '@ericminassian/platform/config'`
49
+ : 'run this inside an app, or create one with `plat init <dir> --name <app>`',
34
50
  exitCode: EXIT.usage,
35
51
  });
36
52
  }
37
- let raw: Record<string, unknown>;
38
- try {
39
- raw = JSON.parse(readFileSync(join(root, MANIFEST_FILE), 'utf8'));
40
- } catch (err) {
41
- throw new CliError(`${MANIFEST_FILE} is not valid JSON: ${(err as Error).message}`, {
53
+ const exported = await importConfig(root);
54
+ if (exported === null || typeof exported !== 'object' || Array.isArray(exported)) {
55
+ throw new CliError(`${APP_CONFIG_FILE} must default-export the app config`, {
56
+ hint: "export default defineApp({ name: '...' }) from '@ericminassian/platform/config'",
42
57
  exitCode: EXIT.usage,
43
58
  });
44
59
  }
60
+ // The platform receives JSON, so validate (and later deploy) the JSON form.
61
+ const raw = JSON.parse(JSON.stringify(exported)) as Record<string, unknown>;
45
62
  try {
46
63
  return { root, raw, manifest: parseManifest(raw) };
47
64
  } catch (err) {
@@ -50,17 +67,54 @@ export function loadProject(cwd: string): Project {
50
67
  }
51
68
  }
52
69
 
53
- /** App name from `--app`, else the nearest platform.json. */
54
- export function resolveAppName(opt: string | undefined, cwd = process.cwd()): string {
70
+ /**
71
+ * Bundles the config (its relative imports included, packages left external)
72
+ * into `.plat/` and imports that, like Vite does for vite.config.ts. This
73
+ * works whatever the app's package `type` is, and a fresh file name means a
74
+ * changed config is always re-read.
75
+ */
76
+ async function importConfig(root: string): Promise<unknown> {
77
+ const outfile = join(root, '.plat', `config-${process.pid}-${Date.now()}.mjs`);
78
+ try {
79
+ await esbuild({
80
+ entryPoints: [join(root, APP_CONFIG_FILE)],
81
+ outfile,
82
+ bundle: true,
83
+ packages: 'external',
84
+ platform: 'node',
85
+ format: 'esm',
86
+ target: 'node24',
87
+ sourcemap: 'inline',
88
+ logLevel: 'silent',
89
+ absWorkingDir: root,
90
+ });
91
+ } catch (err) {
92
+ throw new CliError(`could not compile ${APP_CONFIG_FILE}:${buildFailureList(err)}`, {
93
+ exitCode: EXIT.usage,
94
+ });
95
+ }
96
+ try {
97
+ const mod = (await import(pathToFileURL(outfile).href)) as { default?: unknown };
98
+ return mod.default;
99
+ } catch (err) {
100
+ throw new CliError(`${APP_CONFIG_FILE} threw while loading: ${(err as Error).message}`, {
101
+ exitCode: EXIT.usage,
102
+ });
103
+ } finally {
104
+ rmSync(outfile, { force: true });
105
+ }
106
+ }
107
+
108
+ /** App name from `--app`, else the nearest plat.config.ts. */
109
+ export async function resolveAppName(opt: string | undefined, cwd = process.cwd()): Promise<string> {
55
110
  if (opt) return opt;
56
- const root = findProjectRoot(cwd);
57
- if (!root) {
111
+ if (!findProjectRoot(cwd)) {
58
112
  throw new CliError('no app given', {
59
113
  hint: 'pass --app <name> or run inside an app directory',
60
114
  exitCode: EXIT.usage,
61
115
  });
62
116
  }
63
- return loadProject(root).manifest.name;
117
+ return (await loadProject(cwd)).manifest.name;
64
118
  }
65
119
 
66
120
  export function cliVersion(): string {
@@ -0,0 +1,10 @@
1
+ /** An esbuild failure as `file:line: message` items (or the error's message), for a CliError list. */
2
+ export function buildFailureList(err: unknown): string {
3
+ const errors = (
4
+ err as { errors?: Array<{ text: string; location?: { file: string; line: number } | null }> }
5
+ ).errors;
6
+ const lines = errors?.length
7
+ ? errors.map((e) => (e.location ? `${e.location.file}:${e.location.line}: ${e.text}` : e.text))
8
+ : [(err as Error).message];
9
+ return `\n - ${lines.join('\n - ')}`;
10
+ }
@@ -6,10 +6,13 @@ the app; the platform provisions, deploys, routes, signs users in and monitors i
6
6
 
7
7
  ## Golden rules
8
8
 
9
- 1. **No infrastructure code and no AWS SDK in this repo.** Declare what you need in `platform.json`; use it
10
- through `@ericminassian/platform`. No CDK, CloudFormation, Terraform or `@aws-sdk/*` imports.
11
- 2. **Every name in `platform.json` must match code.** Each queue, event and cron needs a handler in
12
- `src/worker.ts`, and every handler needs a `platform.json` entry.
9
+ 1. **No infrastructure code and no AWS SDK in this repo.** Declare what you need in `plat.config.ts`; use
10
+ it through `@ericminassian/platform`. No CDK, CloudFormation, Terraform or `@aws-sdk/*` imports.
11
+ 2. **`plat.config.ts` is the source of truth for names and payload types.** `pnpm typecheck` fails unless
12
+ `src/worker.ts` handles exactly its queues, events and crons, and every `queue()`, `events.publish()`,
13
+ `storage()` and `secrets.get()` uses a declared name with the declared payload type. Declare message
14
+ types there (`queue<EmailJob>()`, `event<TodoCreated>()`) rather than annotating handlers, and keep the
15
+ `Register` block at the end of the file.
13
16
  3. **Never commit secrets.** Use `plat secrets set NAME` for deployed stages and `.env.local` (git-ignored)
14
17
  for `plat dev`. Declare each name under `secrets`.
15
18
  4. **Run `pnpm typecheck && pnpm test` before you push.** CI runs the same checks, plus `plat test` when
@@ -19,7 +22,7 @@ the app; the platform provisions, deploys, routes, signs users in and monitors i
19
22
 
20
23
  | Path | What |
21
24
  | ---------------------------- | -------------------------------------------------------------------------------------- |
22
- | `platform.json` | Everything the app needs (web, auth, db, queues, events, crons, storage, secrets, env) |
25
+ | `plat.config.ts` | Everything the app needs (web, auth, db, queues, events, crons, storage, secrets, env) |
23
26
  | `src/server.ts` | HTTP API (Hono). `export default app`. Routes under `/api/*` |
24
27
  | `src/worker.ts` | Background handlers: `defineWorker({ queues, events, crons })` |
25
28
  | `src/types.ts` | Types shared by server, worker and web |
@@ -29,7 +32,7 @@ the app; the platform provisions, deploys, routes, signs users in and monitors i
29
32
 
30
33
  Requests for `/api/*` and `/__platform/*` go to the server; files go to `web/dist`. Any other path
31
34
  serves `index.html`, which is how client-side routing works. Change the server prefixes with
32
- `web.serverRoutes` (default `["/api/"]`).
35
+ `web.serverRoutes` (default `['/api/']`).
33
36
 
34
37
  ## SDK reference (`@ericminassian/platform`)
35
38
 
@@ -44,8 +47,8 @@ import { defineWorker, invokeJob } from '@ericminassian/platform/worker';
44
47
  | `db.collection` | `const todos = db.collection<Todo>('todos', { partition: 'userId', sort: 'id' })` |
45
48
  | `.get/.put/.update/.delete` | `await todos.put(t, { ifNotExists: true })`, `await todos.update({ userId, id }, { done: true })` |
46
49
  | `.query` | `await todos.query({ userId }, { beginsWith: '2026', limit: 20, reverse: true })` → `{ items, cursor }` |
47
- | `queue(name).send` | `await queue<EmailJob>('emails').send({ to, subject, body }, { delaySeconds: 60 })` |
48
- | `events.publish` | `await events.publish('todo.created', { id })` (anything can publish; subscribers are listed in `events`) |
50
+ | `queue(name).send` | `await queue('emails').send({ to, subject, body }, { delaySeconds: 60 })` |
51
+ | `events.publish` | `await events.publish('todo.created', { userId, id, title })` |
49
52
  | `storage(name)` | `await storage('uploads').put(key, bytes, { contentType })`, `.get`, `.getText`, `.list(prefix)`, `.delete` |
50
53
  | | `await storage('uploads').uploadUrl(key, { contentType })`: browser-direct upload URL (HTTP PUT) |
51
54
  | `secrets.get` | `await secrets.get('STRIPE_KEY')` (throws if unset), `secrets.getOptional(...)` |
@@ -54,7 +57,7 @@ import { defineWorker, invokeJob } from '@ericminassian/platform/worker';
54
57
  | `auth.loginUrl/logoutUrl` | `auth.loginUrl('/dashboard')` → `/__platform/auth/login?redirect=...` |
55
58
  | `metrics` | `metrics.count('signups')`, `metrics.value('cart.total', 42.5, 'usd')`: free, shows up in the console |
56
59
  | `log` | `log.info('charged', { orderId })`, `log.error('failed', err)`: structured, traced, searchable |
57
- | `defineWorker` | `defineWorker({ queues: { emails: async (msg) => {} }, events: {...}, crons: {...} })` |
60
+ | `defineWorker` | `defineWorker({ queues: { emails: async (msg) => {} }, events: {...}, crons: {...} })`; `msg.body` is typed |
58
61
  | `invokeJob` | `await invokeJob('reindex', { id })`: async call to an `invoke` handler on this stage's worker |
59
62
 
60
63
  User ids (`user.id`) are stable and shared across all platform apps. Every request, job, database call,
@@ -63,32 +66,34 @@ queue send and `fetch` is traced automatically. Don't add your own tracing or me
63
66
  ## Recipes
64
67
 
65
68
  - **New collection or index.** Declare it in code; no infra change is needed. `db: true` must be in
66
- `platform.json`. For a secondary index:
69
+ `plat.config.ts`. For a secondary index:
67
70
  `db.collection<User>('users', { partition: 'id', indexes: { byEmail: { slot: 1, partition: 'email' } } })`,
68
71
  then `users.query({ email }, { index: 'byEmail' })`.
69
72
  - Slots 1-3 are fixed GSIs. Never reuse a slot for a different key in the same collection.
70
73
  - For expiry, set `ttl: 'expiresAt'` (epoch seconds).
71
74
  - **Queue.**
72
- 1. Add `"queues": { "thumbnails": { "batchSize": 5 } }` to `platform.json`.
75
+ 1. Add `thumbnails: queue<ThumbnailJob>({ batchSize: 5 })` under `queues` in `plat.config.ts`, with
76
+ `ThumbnailJob` in `src/types.ts`.
73
77
  2. Add a `queues.thumbnails` handler in `src/worker.ts`.
74
78
  3. Send with `queue('thumbnails').send(...)`.
75
79
 
76
80
  Throwing in the handler retries the message; after `maxReceiveCount` (default 5) it goes to the
77
81
  dead-letter queue, which you can inspect and redrive from the console.
78
- - **Event subscription.** Add the type to `"events"`, then add an `events['order.paid']` handler.
79
- - **Cron.** Add `"crons": { "digest": "0 8 * * 1-5" }` (5-field cron, UTC; or
80
- `{ "schedule": "...", "timezone": "America/Los_Angeles" }`, or `"rate(15 minutes)"`), then add a
82
+ - **Event subscription.** Add `'order.paid': event<OrderPaid>()` under `events`, then add an
83
+ `events['order.paid']` handler. Publish with `events.publish('order.paid', detail)`.
84
+ - **Cron.** Add `digest: '0 8 * * 1-5'` under `crons` (5-field cron, UTC; or
85
+ `{ schedule: '...', timezone: 'America/Los_Angeles' }`, or `'rate(15 minutes)'`), then add a
81
86
  `crons.digest` handler.
82
- - **File store.** Add `"storage": ["avatars"]`, then use `storage('avatars')`. For browser uploads, return
87
+ - **File store.** Add `'avatars'` to `storage`, then use `storage('avatars')`. For browser uploads, return
83
88
  `uploadUrl(...)` from an API route and `PUT` the file to it.
84
89
  - **Secret.**
85
- 1. Add `"secrets": ["STRIPE_KEY"]` to `platform.json`.
90
+ 1. Add `'STRIPE_KEY'` to `secrets` in `plat.config.ts`.
86
91
  2. Set it: `plat secrets set STRIPE_KEY --stage prod`, or `--shared` for all stages.
87
92
  3. Locally, put `STRIPE_KEY=...` in `.env.local`.
88
93
  4. Read it: `await secrets.get('STRIPE_KEY')`.
89
94
  - **Protected route.** Call `const user = await auth.requireUser(c.req.raw)` first. To require sign-in
90
- for everything, set `"auth": { "required": true }`.
91
- - **Plain config.** Use `"env": { "FEATURE_X": "on" }`, read via `process.env.FEATURE_X`. Not for secrets.
95
+ for everything, set `auth: { required: true }`.
96
+ - **Plain config.** Use `env: { FEATURE_X: 'on' }`, read via `process.env.FEATURE_X`. Not for secrets.
92
97
  - **Custom metric.** Use `metrics.count('name')` or `metrics.value('name', n)`. It appears in the console
93
98
  and in `plat metrics`.
94
99
 
@@ -0,0 +1,31 @@
1
+ import { defineApp, event, queue } from '@ericminassian/platform/config';
2
+ import type { EmailJob, TodoCreated } from './src/types.ts';
3
+
4
+ // Everything this app needs from the platform. `plat` evaluates this file and
5
+ // provisions what it declares; the SDK checks names and payload types in code
6
+ // against it (see `Register` below).
7
+ const app = defineApp({
8
+ name: 'template-app',
9
+ description: 'A todo app on the platform',
10
+ web: {
11
+ server: 'src/server.ts',
12
+ static: 'web/dist',
13
+ build: 'vite build --config web/vite.config.ts',
14
+ dev: { command: 'vite --config web/vite.config.ts', port: 5173 },
15
+ spa: true,
16
+ },
17
+ auth: true,
18
+ db: true,
19
+ queues: { emails: queue<EmailJob>() },
20
+ events: { 'todo.created': event<TodoCreated>() },
21
+ crons: { cleanup: '0 3 * * *' },
22
+ storage: ['uploads'],
23
+ });
24
+
25
+ export default app;
26
+
27
+ declare module '@ericminassian/platform' {
28
+ interface Register {
29
+ app: typeof app;
30
+ }
31
+ }
@@ -2,7 +2,7 @@ import { auth, db, events, metrics, app as platform, queue } from '@ericminassia
2
2
  import { Hono } from 'hono';
3
3
  import { validator } from 'hono/validator';
4
4
  import { z } from 'zod';
5
- import type { EmailJob, Todo, TodoCreated } from './types.ts';
5
+ import type { Todo } from './types.ts';
6
6
 
7
7
  // One collection per entity. Partition by user so each user's todos are one query.
8
8
  // Sort keys are time-ordered ids, so `query` returns oldest first.
@@ -50,9 +50,9 @@ const api = new Hono()
50
50
  createdAt: new Date().toISOString(),
51
51
  };
52
52
  await todos.put(todo, { ifNotExists: true });
53
- await events.publish<TodoCreated>('todo.created', { userId: user.id, id: todo.id, title: todo.title });
53
+ await events.publish('todo.created', { userId: user.id, id: todo.id, title: todo.title });
54
54
  if (user.email) {
55
- await queue<EmailJob>('emails').send({ to: user.email, subject: 'New todo', body: todo.title });
55
+ await queue('emails').send({ to: user.email, subject: 'New todo', body: todo.title });
56
56
  }
57
57
  metrics.count('todos.created');
58
58
  return c.json({ todo }, 201);
@@ -1,12 +1,12 @@
1
1
  import { log, metrics } from '@ericminassian/platform';
2
- import { defineWorker, type PlatformEvent, type QueueMessage } from '@ericminassian/platform/worker';
3
- import type { EmailJob, TodoCreated } from './types.ts';
2
+ import { defineWorker } from '@ericminassian/platform/worker';
4
3
 
5
- // Every key here must match platform.json: `queues`, `events` and `crons`.
4
+ // Handles exactly the `queues`, `events` and `crons` in plat.config.ts (a missing
5
+ // or extra handler is a type error), with the payload types declared there.
6
6
  // Throwing retries the job (queues: up to maxReceiveCount, then the dead-letter queue).
7
7
  export default defineWorker({
8
8
  queues: {
9
- emails: async (message: QueueMessage<EmailJob>) => {
9
+ emails: async (message) => {
10
10
  // Swap in a real email provider here (store its API key with `plat secrets set`).
11
11
  log.info('would send email', {
12
12
  to: message.body.to,
@@ -18,7 +18,7 @@ export default defineWorker({
18
18
  },
19
19
 
20
20
  events: {
21
- 'todo.created': async (event: PlatformEvent<TodoCreated>) => {
21
+ 'todo.created': async (event) => {
22
22
  log.info('todo created', { id: event.detail.id, userId: event.detail.userId });
23
23
  },
24
24
  },
@@ -14,5 +14,5 @@
14
14
  "skipLibCheck": true,
15
15
  "noEmit": true
16
16
  },
17
- "include": ["src", "test", "vitest.config.ts", "vitest.integration.config.ts"]
17
+ "include": ["plat.config.ts", "src", "test", "vitest.config.ts", "vitest.integration.config.ts"]
18
18
  }
@@ -1,18 +0,0 @@
1
- {
2
- "$schema": "https://raw.githubusercontent.com/eric-minassian/platform/main/packages/core/platform.schema.json",
3
- "name": "template-app",
4
- "description": "A todo app on the platform",
5
- "web": {
6
- "server": "src/server.ts",
7
- "static": "web/dist",
8
- "build": "vite build --config web/vite.config.ts",
9
- "dev": { "command": "vite --config web/vite.config.ts", "port": 5173 },
10
- "spa": true
11
- },
12
- "auth": true,
13
- "db": true,
14
- "queues": { "emails": {} },
15
- "events": ["todo.created"],
16
- "crons": { "cleanup": "0 3 * * *" },
17
- "storage": ["uploads"]
18
- }