@ericminassian/plat 0.0.0-stage → 0.1.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/dist/api-types.d.ts +7 -0
- package/dist/api-types.js +3 -0
- package/dist/api-types.js.map +1 -0
- package/dist/api.d.ts +123 -0
- package/dist/api.js +271 -0
- package/dist/api.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/build.d.ts +45 -0
- package/dist/build.js +188 -0
- package/dist/build.js.map +1 -0
- package/dist/commands/account.d.ts +2 -0
- package/dist/commands/account.js +45 -0
- package/dist/commands/account.js.map +1 -0
- package/dist/commands/apps.d.ts +2 -0
- package/dist/commands/apps.js +75 -0
- package/dist/commands/apps.js.map +1 -0
- package/dist/commands/common.d.ts +19 -0
- package/dist/commands/common.js +43 -0
- package/dist/commands/common.js.map +1 -0
- package/dist/commands/lifecycle.d.ts +3 -0
- package/dist/commands/lifecycle.js +105 -0
- package/dist/commands/lifecycle.js.map +1 -0
- package/dist/commands/manage.d.ts +2 -0
- package/dist/commands/manage.js +103 -0
- package/dist/commands/manage.js.map +1 -0
- package/dist/commands/observe.d.ts +8 -0
- package/dist/commands/observe.js +195 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +42 -0
- package/dist/credentials.js.map +1 -0
- package/dist/deploy.d.ts +23 -0
- package/dist/deploy.js +100 -0
- package/dist/deploy.js.map +1 -0
- package/dist/dev/crons.d.ts +16 -0
- package/dist/dev/crons.js +47 -0
- package/dist/dev/crons.js.map +1 -0
- package/dist/dev/entries.d.ts +6 -0
- package/dist/dev/entries.js +21 -0
- package/dist/dev/entries.js.map +1 -0
- package/dist/dev/index.d.ts +33 -0
- package/dist/dev/index.js +262 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/local-stage.d.ts +46 -0
- package/dist/dev/local-stage.js +116 -0
- package/dist/dev/local-stage.js.map +1 -0
- package/dist/dev/pretty.d.ts +16 -0
- package/dist/dev/pretty.js +91 -0
- package/dist/dev/pretty.js.map +1 -0
- package/dist/dev/proc.d.ts +7 -0
- package/dist/dev/proc.js +16 -0
- package/dist/dev/proc.js.map +1 -0
- package/dist/dev/router.d.ts +67 -0
- package/dist/dev/router.js +184 -0
- package/dist/dev/router.js.map +1 -0
- package/dist/dev/server-entry.d.ts +1 -0
- package/dist/dev/server-entry.js +28 -0
- package/dist/dev/server-entry.js.map +1 -0
- package/dist/dev/test.d.ts +10 -0
- package/dist/dev/test.js +62 -0
- package/dist/dev/test.js.map +1 -0
- package/dist/dev/worker-host.d.ts +1 -0
- package/dist/dev/worker-host.js +67 -0
- package/dist/dev/worker-host.js.map +1 -0
- package/dist/dev/worker.d.ts +57 -0
- package/dist/dev/worker.js +216 -0
- package/dist/dev/worker.js.map +1 -0
- package/dist/github.d.ts +39 -0
- package/dist/github.js +108 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +23 -0
- package/dist/init.js +102 -0
- package/dist/init.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +24 -0
- package/dist/main.js.map +1 -0
- package/dist/project.d.ts +22 -0
- package/dist/project.js +81 -0
- package/dist/project.js.map +1 -0
- package/dist/util/net.d.ts +4 -0
- package/dist/util/net.js +33 -0
- package/dist/util/net.js.map +1 -0
- package/dist/util/output.d.ts +42 -0
- package/dist/util/output.js +105 -0
- package/dist/util/output.js.map +1 -0
- package/dist/util/prompt.d.ts +3 -0
- package/dist/util/prompt.js +22 -0
- package/dist/util/prompt.js.map +1 -0
- package/dist/util/time.d.ts +11 -0
- package/dist/util/time.js +29 -0
- package/dist/util/time.js.map +1 -0
- package/dist/util/zip.d.ts +10 -0
- package/dist/util/zip.js +45 -0
- package/dist/util/zip.js.map +1 -0
- package/package.json +50 -3
- package/src/api-types.ts +8 -0
- package/src/api.ts +341 -0
- package/src/bin.ts +4 -0
- package/src/build.ts +255 -0
- package/src/commands/account.ts +62 -0
- package/src/commands/apps.ts +102 -0
- package/src/commands/common.ts +51 -0
- package/src/commands/lifecycle.ts +139 -0
- package/src/commands/manage.ts +169 -0
- package/src/commands/observe.ts +276 -0
- package/src/credentials.ts +50 -0
- package/src/deploy.ts +138 -0
- package/src/dev/crons.ts +70 -0
- package/src/dev/entries.ts +23 -0
- package/src/dev/index.ts +281 -0
- package/src/dev/local-stage.ts +176 -0
- package/src/dev/pretty.ts +90 -0
- package/src/dev/proc.ts +15 -0
- package/src/dev/router.ts +246 -0
- package/src/dev/server-entry.ts +21 -0
- package/src/dev/test.ts +68 -0
- package/src/dev/worker-host.ts +62 -0
- package/src/dev/worker.ts +332 -0
- package/src/github.ts +121 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/main.ts +28 -0
- package/src/project.ts +89 -0
- package/src/util/net.ts +33 -0
- package/src/util/output.ts +117 -0
- package/src/util/prompt.ts +20 -0
- package/src/util/time.ts +32 -0
- package/src/util/zip.ts +46 -0
- package/template/.github/workflows/platform.yml +24 -0
- package/template/AGENTS.md +134 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +13 -0
- package/template/_gitignore +8 -0
- package/template/package.json +31 -0
- package/template/platform.json +18 -0
- package/template/src/server.ts +100 -0
- package/template/src/types.ts +21 -0
- package/template/src/worker.ts +32 -0
- package/template/test/server.test.ts +120 -0
- package/template/test/todos.integration.test.ts +30 -0
- package/template/tsconfig.json +18 -0
- package/template/vitest.config.ts +9 -0
- package/template/vitest.integration.config.ts +10 -0
- package/template/web/index.html +12 -0
- package/template/web/src/App.tsx +102 -0
- package/template/web/src/api.ts +11 -0
- package/template/web/src/main.tsx +10 -0
- package/template/web/src/styles.css +102 -0
- package/template/web/tsconfig.json +18 -0
- package/template/web/vite.config.ts +19 -0
- package/README.md +0 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { auth, db, events, metrics, app as platform, queue } from '@ericminassian/platform';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { validator } from 'hono/validator';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { EmailJob, Todo, TodoCreated } from './types.ts';
|
|
6
|
+
|
|
7
|
+
// One collection per entity. Partition by user so each user's todos are one query.
|
|
8
|
+
// Sort keys are time-ordered ids, so `query` returns oldest first.
|
|
9
|
+
const todos = db.collection<Todo>('todos', { partition: 'userId', sort: 'id' });
|
|
10
|
+
|
|
11
|
+
const NewTodo = z.object({ title: z.string().trim().min(1).max(200) });
|
|
12
|
+
const PatchTodo = z.object({
|
|
13
|
+
title: z.string().trim().min(1).max(200).optional(),
|
|
14
|
+
done: z.boolean().optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Validates a JSON body with a zod schema; the parsed type flows to the typed web client. */
|
|
18
|
+
const json = <T extends z.ZodType>(schema: T) =>
|
|
19
|
+
validator('json', (value, c) => {
|
|
20
|
+
const parsed = schema.safeParse(value);
|
|
21
|
+
if (!parsed.success) return c.json({ error: parsed.error.issues[0]?.message ?? 'invalid body' }, 400);
|
|
22
|
+
return parsed.data as z.infer<T>;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Time-sortable id: 9 chars of base36 ms timestamp + 8 random chars. */
|
|
26
|
+
function newId(): string {
|
|
27
|
+
return Date.now().toString(36).padStart(9, '0') + crypto.randomUUID().replace(/-/g, '').slice(0, 8);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const api = new Hono()
|
|
31
|
+
.get('/health', (c) => c.json({ ok: true, app: platform.name, stage: platform.stage }))
|
|
32
|
+
|
|
33
|
+
.get('/me', async (c) => c.json({ user: await auth.getUser(c.req.raw) }))
|
|
34
|
+
|
|
35
|
+
.get('/todos', async (c) => {
|
|
36
|
+
const user = await auth.requireUser(c.req.raw);
|
|
37
|
+
const page = await todos.query({ userId: user.id });
|
|
38
|
+
return c.json({ todos: page.items });
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
.post('/todos', json(NewTodo), async (c) => {
|
|
42
|
+
const user = await auth.requireUser(c.req.raw);
|
|
43
|
+
const input = c.req.valid('json');
|
|
44
|
+
|
|
45
|
+
const todo: Todo = {
|
|
46
|
+
userId: user.id,
|
|
47
|
+
id: newId(),
|
|
48
|
+
title: input.title,
|
|
49
|
+
done: false,
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
await todos.put(todo, { ifNotExists: true });
|
|
53
|
+
await events.publish<TodoCreated>('todo.created', { userId: user.id, id: todo.id, title: todo.title });
|
|
54
|
+
if (user.email) {
|
|
55
|
+
await queue<EmailJob>('emails').send({ to: user.email, subject: 'New todo', body: todo.title });
|
|
56
|
+
}
|
|
57
|
+
metrics.count('todos.created');
|
|
58
|
+
return c.json({ todo }, 201);
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
.patch('/todos/:id', json(PatchTodo), async (c) => {
|
|
62
|
+
const user = await auth.requireUser(c.req.raw);
|
|
63
|
+
const key = { userId: user.id, id: c.req.param('id') };
|
|
64
|
+
if (!(await todos.get(key))) return c.json({ error: 'not found' }, 404);
|
|
65
|
+
const todo = await todos.update(key, c.req.valid('json'));
|
|
66
|
+
return c.json({ todo });
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
.delete('/todos/:id', async (c) => {
|
|
70
|
+
const user = await auth.requireUser(c.req.raw);
|
|
71
|
+
await todos.delete({ userId: user.id, id: c.req.param('id') });
|
|
72
|
+
return c.body(null, 204);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const app = new Hono();
|
|
76
|
+
|
|
77
|
+
// `auth.requireUser` throws a 401 Response when signed out: send it as-is.
|
|
78
|
+
app.use(async (c, next) => {
|
|
79
|
+
try {
|
|
80
|
+
await next();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err instanceof Response) {
|
|
83
|
+
c.res = err;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
app.route('/api', api);
|
|
90
|
+
|
|
91
|
+
// Anything else is a bug: rethrow so the platform records it (error group, trace
|
|
92
|
+
// id, stack) and answers 500, instead of Hono swallowing it.
|
|
93
|
+
app.onError((err) => {
|
|
94
|
+
throw err;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Typed client for the frontend: `hc<ApiType>('/api')` from `hono/client`. */
|
|
98
|
+
export type ApiType = typeof api;
|
|
99
|
+
|
|
100
|
+
export default app;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Shared between the server, the worker and the web client. */
|
|
2
|
+
|
|
3
|
+
export type Todo = {
|
|
4
|
+
userId: string;
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
done: boolean;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type EmailJob = {
|
|
12
|
+
to: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
body: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TodoCreated = {
|
|
18
|
+
userId: string;
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
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';
|
|
4
|
+
|
|
5
|
+
// Every key here must match platform.json: `queues`, `events` and `crons`.
|
|
6
|
+
// Throwing retries the job (queues: up to maxReceiveCount, then the dead-letter queue).
|
|
7
|
+
export default defineWorker({
|
|
8
|
+
queues: {
|
|
9
|
+
emails: async (message: QueueMessage<EmailJob>) => {
|
|
10
|
+
// Swap in a real email provider here (store its API key with `plat secrets set`).
|
|
11
|
+
log.info('would send email', {
|
|
12
|
+
to: message.body.to,
|
|
13
|
+
subject: message.body.subject,
|
|
14
|
+
attempt: message.attempt,
|
|
15
|
+
});
|
|
16
|
+
metrics.count('emails.sent');
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
events: {
|
|
21
|
+
'todo.created': async (event: PlatformEvent<TodoCreated>) => {
|
|
22
|
+
log.info('todo created', { id: event.detail.id, userId: event.detail.userId });
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
crons: {
|
|
27
|
+
cleanup: async () => {
|
|
28
|
+
log.info('nightly cleanup ran');
|
|
29
|
+
metrics.count('cleanup.runs');
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Unit tests replace the platform SDK with in-memory fakes, so they run with no
|
|
4
|
+
// emulator and no AWS. Integration tests (`*.integration.test.ts`, `plat test`)
|
|
5
|
+
// use the real SDK against the AWS Emulator.
|
|
6
|
+
const store = new Map<string, Record<string, unknown>>();
|
|
7
|
+
const published: Array<{ type: string; detail: unknown }> = [];
|
|
8
|
+
const sent: unknown[] = [];
|
|
9
|
+
|
|
10
|
+
vi.mock('@ericminassian/platform', () => {
|
|
11
|
+
const userFrom = (req: Request) => {
|
|
12
|
+
const id = req.headers.get('x-test-user');
|
|
13
|
+
return id ? { id, email: `${id}@example.com`, groups: [] } : null;
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
app: {
|
|
17
|
+
name: 'template-app',
|
|
18
|
+
stage: 'test',
|
|
19
|
+
host: 'localhost',
|
|
20
|
+
url: 'http://localhost',
|
|
21
|
+
revision: 'd0',
|
|
22
|
+
isLocal: true,
|
|
23
|
+
},
|
|
24
|
+
auth: {
|
|
25
|
+
getUser: async (req: Request) => userFrom(req),
|
|
26
|
+
requireUser: async (req: Request) => {
|
|
27
|
+
const u = userFrom(req);
|
|
28
|
+
if (!u) throw new Response('unauthorized', { status: 401 });
|
|
29
|
+
return u;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
db: {
|
|
33
|
+
collection: (name: string) => {
|
|
34
|
+
const k = (key: Record<string, unknown>) => `${name}#${key.userId}#${key.id}`;
|
|
35
|
+
return {
|
|
36
|
+
get: async (key: Record<string, unknown>) => store.get(k(key)),
|
|
37
|
+
put: async (item: Record<string, unknown>) => {
|
|
38
|
+
store.set(k(item), item);
|
|
39
|
+
return item;
|
|
40
|
+
},
|
|
41
|
+
update: async (key: Record<string, unknown>, patch: Record<string, unknown>) => {
|
|
42
|
+
const next = { ...store.get(k(key)), ...patch };
|
|
43
|
+
store.set(k(key), next);
|
|
44
|
+
return next;
|
|
45
|
+
},
|
|
46
|
+
delete: async (key: Record<string, unknown>) => void store.delete(k(key)),
|
|
47
|
+
query: async (part: Record<string, unknown>) => ({
|
|
48
|
+
items: [...store.entries()]
|
|
49
|
+
.filter(([key]) => key.startsWith(`${name}#${part.userId}#`))
|
|
50
|
+
.map(([, v]) => v),
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
events: { publish: async (type: string, detail: unknown) => void published.push({ type, detail }) },
|
|
56
|
+
queue: () => ({ send: async (body: unknown) => void sent.push(body), sendBatch: async () => {} }),
|
|
57
|
+
metrics: { count: () => {}, value: () => {} },
|
|
58
|
+
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const { default: app } = await import('../src/server.ts');
|
|
63
|
+
|
|
64
|
+
const as = (user: string) => ({ 'x-test-user': user, 'content-type': 'application/json' });
|
|
65
|
+
|
|
66
|
+
describe('api', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
store.clear();
|
|
69
|
+
published.length = 0;
|
|
70
|
+
sent.length = 0;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reports health', async () => {
|
|
74
|
+
const res = await app.request('/api/health');
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
expect(await res.json()).toMatchObject({ ok: true, app: 'template-app' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('requires sign-in for todos', async () => {
|
|
80
|
+
const res = await app.request('/api/todos');
|
|
81
|
+
expect(res.status).toBe(401);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('creates, lists, toggles and deletes todos', async () => {
|
|
85
|
+
const created = await app.request('/api/todos', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: as('u1'),
|
|
88
|
+
body: JSON.stringify({ title: 'Buy milk' }),
|
|
89
|
+
});
|
|
90
|
+
expect(created.status).toBe(201);
|
|
91
|
+
const { todo } = (await created.json()) as { todo: { id: string; done: boolean } };
|
|
92
|
+
expect(published).toEqual([{ type: 'todo.created', detail: expect.objectContaining({ id: todo.id }) }]);
|
|
93
|
+
expect(sent).toHaveLength(1);
|
|
94
|
+
|
|
95
|
+
const list = await app.request('/api/todos', { headers: as('u1') });
|
|
96
|
+
expect(((await list.json()) as { todos: unknown[] }).todos).toHaveLength(1);
|
|
97
|
+
const other = await app.request('/api/todos', { headers: as('u2') });
|
|
98
|
+
expect(((await other.json()) as { todos: unknown[] }).todos).toHaveLength(0);
|
|
99
|
+
|
|
100
|
+
const patched = await app.request(`/api/todos/${todo.id}`, {
|
|
101
|
+
method: 'PATCH',
|
|
102
|
+
headers: as('u1'),
|
|
103
|
+
body: JSON.stringify({ done: true }),
|
|
104
|
+
});
|
|
105
|
+
expect(((await patched.json()) as { todo: { done: boolean } }).todo.done).toBe(true);
|
|
106
|
+
|
|
107
|
+
const deleted = await app.request(`/api/todos/${todo.id}`, { method: 'DELETE', headers: as('u1') });
|
|
108
|
+
expect(deleted.status).toBe(204);
|
|
109
|
+
expect(store.size).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('validates input', async () => {
|
|
113
|
+
const res = await app.request('/api/todos', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: as('u1'),
|
|
116
|
+
body: JSON.stringify({ title: '' }),
|
|
117
|
+
});
|
|
118
|
+
expect(res.status).toBe(400);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { db } from '@ericminassian/platform';
|
|
2
|
+
import { createPlatformFetch } from '@ericminassian/platform/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import app from '../src/server.ts';
|
|
5
|
+
import type { Todo } from '../src/types.ts';
|
|
6
|
+
|
|
7
|
+
// Runs under `plat test`: real SDK, real (emulated) DynamoDB/SQS/EventBridge.
|
|
8
|
+
const fetchApp = createPlatformFetch(app);
|
|
9
|
+
|
|
10
|
+
describe('integration', () => {
|
|
11
|
+
it('serves health through the platform wrapper', async () => {
|
|
12
|
+
const res = await fetchApp(new Request('http://localhost/api/health'));
|
|
13
|
+
expect(res.status).toBe(200);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('round-trips a todo through the database', async () => {
|
|
17
|
+
const todos = db.collection<Todo>('todos', { partition: 'userId', sort: 'id' });
|
|
18
|
+
const todo: Todo = {
|
|
19
|
+
userId: 'it-user',
|
|
20
|
+
id: `t-${Date.now()}`,
|
|
21
|
+
title: 'from a test',
|
|
22
|
+
done: false,
|
|
23
|
+
createdAt: new Date().toISOString(),
|
|
24
|
+
};
|
|
25
|
+
await todos.put(todo);
|
|
26
|
+
expect(await todos.get({ userId: todo.userId, id: todo.id })).toMatchObject({ title: 'from a test' });
|
|
27
|
+
const page = await todos.query({ userId: todo.userId });
|
|
28
|
+
expect(page.items.map((t) => t.id)).toContain(todo.id);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"lib": ["ES2024"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"noEmit": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src", "test", "vitest.config.ts", "vitest.integration.config.ts"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { configDefaults, defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
// Unit tests: fast, no emulator. `*.integration.test.ts` runs under `plat test`.
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
include: ['test/**/*.test.ts'],
|
|
7
|
+
exclude: [...configDefaults.exclude, '**/*.integration.test.ts'],
|
|
8
|
+
},
|
|
9
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
// Integration tests: `plat test` starts the AWS Emulator, provisions this app
|
|
4
|
+
// (stage `test`) and runs these with the same environment deployed code gets.
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
test: {
|
|
7
|
+
include: ['test/**/*.integration.test.ts'],
|
|
8
|
+
testTimeout: 30_000,
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>template-app</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type FormEvent, useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { Todo } from '../../src/types.ts';
|
|
3
|
+
import { api, signInUrl, signOutUrl } from './api.ts';
|
|
4
|
+
|
|
5
|
+
type User = { id: string; email?: string; name?: string };
|
|
6
|
+
|
|
7
|
+
export function App() {
|
|
8
|
+
const [user, setUser] = useState<User | null | undefined>(undefined);
|
|
9
|
+
const [todos, setTodos] = useState<Todo[]>([]);
|
|
10
|
+
const [title, setTitle] = useState('');
|
|
11
|
+
const [error, setError] = useState<string | null>(null);
|
|
12
|
+
|
|
13
|
+
const load = useCallback(async () => {
|
|
14
|
+
const res = await api.todos.$get();
|
|
15
|
+
if (res.ok) setTodos((await res.json()).todos);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
void (async () => {
|
|
20
|
+
const res = await api.me.$get();
|
|
21
|
+
const me = res.ok ? (await res.json()).user : null;
|
|
22
|
+
setUser(me);
|
|
23
|
+
if (me) await load();
|
|
24
|
+
})();
|
|
25
|
+
}, [load]);
|
|
26
|
+
|
|
27
|
+
async function add(e: FormEvent) {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
setError(null);
|
|
30
|
+
const res = await api.todos.$post({ json: { title } });
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
setError('Could not add that todo.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setTitle('');
|
|
36
|
+
await load();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function toggle(todo: Todo) {
|
|
40
|
+
await api.todos[':id'].$patch({ param: { id: todo.id }, json: { done: !todo.done } });
|
|
41
|
+
await load();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function remove(todo: Todo) {
|
|
45
|
+
await api.todos[':id'].$delete({ param: { id: todo.id } });
|
|
46
|
+
await load();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (user === undefined) return <main className="card">Loading…</main>;
|
|
50
|
+
|
|
51
|
+
if (!user) {
|
|
52
|
+
return (
|
|
53
|
+
<main className="card">
|
|
54
|
+
<h1>template-app</h1>
|
|
55
|
+
<p>Sign in to keep a todo list.</p>
|
|
56
|
+
<a className="button" href={signInUrl()}>
|
|
57
|
+
Sign in
|
|
58
|
+
</a>
|
|
59
|
+
</main>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<main className="card">
|
|
65
|
+
<header>
|
|
66
|
+
<h1>Todos</h1>
|
|
67
|
+
<span>
|
|
68
|
+
{user.email ?? user.name ?? user.id} · <a href={signOutUrl()}>Sign out</a>
|
|
69
|
+
</span>
|
|
70
|
+
</header>
|
|
71
|
+
<form onSubmit={add}>
|
|
72
|
+
<input
|
|
73
|
+
value={title}
|
|
74
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
75
|
+
placeholder="What needs doing?"
|
|
76
|
+
required
|
|
77
|
+
/>
|
|
78
|
+
<button type="submit">Add</button>
|
|
79
|
+
</form>
|
|
80
|
+
{error && <p className="error">{error}</p>}
|
|
81
|
+
<ul>
|
|
82
|
+
{todos.map((t) => (
|
|
83
|
+
<li key={t.id}>
|
|
84
|
+
<label>
|
|
85
|
+
<input type="checkbox" checked={t.done} onChange={() => void toggle(t)} />
|
|
86
|
+
<span className={t.done ? 'done' : undefined}>{t.title}</span>
|
|
87
|
+
</label>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="link"
|
|
91
|
+
onClick={() => void remove(t)}
|
|
92
|
+
aria-label={`Delete ${t.title}`}
|
|
93
|
+
>
|
|
94
|
+
✕
|
|
95
|
+
</button>
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
{todos.length === 0 && <li className="empty">Nothing yet.</li>}
|
|
99
|
+
</ul>
|
|
100
|
+
</main>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { hc } from 'hono/client';
|
|
2
|
+
import type { ApiType } from '../../src/server.ts';
|
|
3
|
+
|
|
4
|
+
/** Typed API client: routes, params and JSON bodies come from src/server.ts. */
|
|
5
|
+
export const api = hc<ApiType>('/api');
|
|
6
|
+
|
|
7
|
+
/** Platform-provided sign-in/out routes (same origin, cookie session). */
|
|
8
|
+
export const signInUrl = (redirect = window.location.pathname) =>
|
|
9
|
+
`/__platform/auth/login?redirect=${encodeURIComponent(redirect)}`;
|
|
10
|
+
export const signOutUrl = (redirect = '/') =>
|
|
11
|
+
`/__platform/auth/logout?redirect=${encodeURIComponent(redirect)}`;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f6f6f4;
|
|
3
|
+
--fg: #1a1a1a;
|
|
4
|
+
--muted: #6b6b6b;
|
|
5
|
+
--card: #fff;
|
|
6
|
+
--border: #e3e3e0;
|
|
7
|
+
--accent: #1a1a1a;
|
|
8
|
+
--accent-fg: #fff;
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
font:
|
|
11
|
+
16px / 1.5 system-ui,
|
|
12
|
+
sans-serif;
|
|
13
|
+
}
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root {
|
|
16
|
+
--bg: #121212;
|
|
17
|
+
--fg: #ececec;
|
|
18
|
+
--muted: #9a9a9a;
|
|
19
|
+
--card: #1c1c1c;
|
|
20
|
+
--border: #2e2e2e;
|
|
21
|
+
--accent: #ececec;
|
|
22
|
+
--accent-fg: #121212;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
body {
|
|
26
|
+
margin: 0;
|
|
27
|
+
background: var(--bg);
|
|
28
|
+
color: var(--fg);
|
|
29
|
+
}
|
|
30
|
+
.card {
|
|
31
|
+
max-width: 520px;
|
|
32
|
+
margin: 10vh auto;
|
|
33
|
+
padding: 24px;
|
|
34
|
+
background: var(--card);
|
|
35
|
+
border: 1px solid var(--border);
|
|
36
|
+
border-radius: 12px;
|
|
37
|
+
}
|
|
38
|
+
header {
|
|
39
|
+
display: flex;
|
|
40
|
+
justify-content: space-between;
|
|
41
|
+
align-items: baseline;
|
|
42
|
+
gap: 12px;
|
|
43
|
+
color: var(--muted);
|
|
44
|
+
}
|
|
45
|
+
header h1 {
|
|
46
|
+
color: var(--fg);
|
|
47
|
+
}
|
|
48
|
+
form {
|
|
49
|
+
display: flex;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
}
|
|
52
|
+
input:not([type="checkbox"]) {
|
|
53
|
+
flex: 1;
|
|
54
|
+
padding: 8px 10px;
|
|
55
|
+
border: 1px solid var(--border);
|
|
56
|
+
border-radius: 8px;
|
|
57
|
+
background: transparent;
|
|
58
|
+
color: inherit;
|
|
59
|
+
font: inherit;
|
|
60
|
+
}
|
|
61
|
+
button,
|
|
62
|
+
.button {
|
|
63
|
+
padding: 8px 14px;
|
|
64
|
+
border: 0;
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
background: var(--accent);
|
|
67
|
+
color: var(--accent-fg);
|
|
68
|
+
font: inherit;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
display: inline-block;
|
|
72
|
+
}
|
|
73
|
+
button.link {
|
|
74
|
+
background: none;
|
|
75
|
+
color: var(--muted);
|
|
76
|
+
padding: 4px 8px;
|
|
77
|
+
}
|
|
78
|
+
ul {
|
|
79
|
+
list-style: none;
|
|
80
|
+
padding: 0;
|
|
81
|
+
}
|
|
82
|
+
li {
|
|
83
|
+
display: flex;
|
|
84
|
+
justify-content: space-between;
|
|
85
|
+
align-items: center;
|
|
86
|
+
padding: 8px 0;
|
|
87
|
+
border-bottom: 1px solid var(--border);
|
|
88
|
+
}
|
|
89
|
+
.done {
|
|
90
|
+
text-decoration: line-through;
|
|
91
|
+
color: var(--muted);
|
|
92
|
+
}
|
|
93
|
+
.empty,
|
|
94
|
+
.error {
|
|
95
|
+
color: var(--muted);
|
|
96
|
+
}
|
|
97
|
+
.error {
|
|
98
|
+
color: #c33;
|
|
99
|
+
}
|
|
100
|
+
a {
|
|
101
|
+
color: inherit;
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"types": ["vite/client", "node"],
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"noEmit": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src", "vite.config.ts"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import { defineConfig } from 'vite';
|
|
4
|
+
|
|
5
|
+
// `plat dev` serves everything on its local router (default :3000) and proxies
|
|
6
|
+
// frontend requests here; opening this dev server directly also works because
|
|
7
|
+
// API and platform routes are proxied back to the router.
|
|
8
|
+
const router = process.env.PLATFORM_DEV_ROUTER ?? 'http://localhost:3000';
|
|
9
|
+
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
root: fileURLToPath(new URL('.', import.meta.url)),
|
|
12
|
+
plugins: [react()],
|
|
13
|
+
build: { outDir: 'dist', emptyOutDir: true },
|
|
14
|
+
server: {
|
|
15
|
+
port: 5173,
|
|
16
|
+
strictPort: true,
|
|
17
|
+
proxy: { '/api': router, '/__platform': router },
|
|
18
|
+
},
|
|
19
|
+
});
|
package/README.md
DELETED