@trypattern/sdk 0.1.12 → 0.1.13
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 +73 -12
- package/dist/access.d.ts +0 -4
- package/dist/access.js +0 -5
- package/dist/api.d.ts +3 -1
- package/dist/api.js +5 -23
- package/dist/app.d.ts +20 -5
- package/dist/app.js +32 -2
- package/dist/blocks.d.ts +18 -0
- package/dist/blocks.js +36 -0
- package/dist/bookings.d.ts +400 -0
- package/dist/bookings.js +300 -0
- package/dist/bookingsMemory.d.ts +9 -0
- package/dist/bookingsMemory.js +79 -0
- package/dist/bookingsStore.d.ts +698 -0
- package/dist/bookingsStore.js +129 -0
- package/dist/cli/app.js +143 -59
- package/dist/cli/publishing.d.ts +3 -1
- package/dist/cli/publishing.js +33 -5
- package/dist/cli/starter.d.ts +17 -0
- package/dist/cli/starter.js +140 -0
- package/dist/components/Calendar.d.ts +20 -0
- package/dist/components/Calendar.js +195 -0
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +7 -29
- package/dist/components/Choice.js +27 -13
- package/dist/components/ChoiceGroup.d.ts +3 -1
- package/dist/components/ChoiceGroup.js +3 -4
- package/dist/components/Dialog.d.ts +1 -1
- package/dist/components/Dialog.js +3 -1
- package/dist/components/ListRow.js +2 -1
- package/dist/components/RadioGroup.d.ts +2 -1
- package/dist/components/RadioGroup.js +10 -2
- package/dist/components/Text.d.ts +1 -1
- package/dist/components/Text.js +43 -2
- package/dist/components/calendarDates.d.ts +14 -0
- package/dist/components/calendarDates.js +84 -0
- package/dist/components/controlLook.js +11 -11
- package/dist/components/icons.d.ts +2 -0
- package/dist/components/icons.js +2 -0
- package/dist/components/interaction.d.ts +1 -1
- package/dist/flows/browser.d.ts +7 -1
- package/dist/flows/browser.js +25 -7
- package/dist/flows/flowBrowser.d.ts +2 -2
- package/dist/flows/flowBrowser.js +2 -2
- package/dist/flows/flows.js +8 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/media.d.ts +11 -1
- package/dist/patternAccess.d.ts +11 -2
- package/dist/patternAccess.js +8 -4
- package/dist/publishWire.d.ts +24 -2
- package/dist/publishWire.js +22 -2
- package/dist/react.d.ts +2 -0
- package/dist/react.js +1 -0
- package/dist/rules/checkAllFiles.js +16 -2
- package/dist/rules/lintPlugin.js +1 -0
- package/dist/schema.d.ts +3 -1
- package/dist/schema.js +1 -0
- package/dist/server/bookings.d.ts +12 -0
- package/dist/server/bookings.js +142 -0
- package/dist/server/createServer.d.ts +3 -2
- package/dist/server/createServer.js +13 -4
- package/dist/server/env.d.ts +8 -0
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/dist/server/media.d.ts +16 -3
- package/dist/server/media.js +72 -23
- package/dist/server/patternViewer.js +16 -10
- package/dist/server/tables.d.ts +2 -0
- package/dist/server/tables.js +17 -1
- package/dist/start.js +12 -0
- package/dist/table.js +9 -3
- package/dist/theme/scales.d.ts +1 -1
- package/dist/theme/scales.js +1 -0
- package/dist/theme/tokens.stylex.d.ts +18 -0
- package/dist/theme/tokens.stylex.js +19 -1
- package/dist/vite/localWorker.d.ts +2 -0
- package/dist/vite/localWorker.js +48 -3
- package/dist/vite/vite.js +5 -1
- package/package.json +1 -1
|
@@ -390,6 +390,14 @@ function isExported(node) {
|
|
|
390
390
|
return (ts.canHaveModifiers(node) &&
|
|
391
391
|
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
|
|
392
392
|
}
|
|
393
|
+
function definesTables(node, source) {
|
|
394
|
+
if (ts.isCallExpression(node)) {
|
|
395
|
+
return node.expression.getText(source) === 'defineTables';
|
|
396
|
+
}
|
|
397
|
+
return (ts.isObjectLiteralExpression(node) &&
|
|
398
|
+
node.properties.some((property) => ts.isSpreadAssignment(property) &&
|
|
399
|
+
definesTables(property.expression, source)));
|
|
400
|
+
}
|
|
393
401
|
function schemaFindings() {
|
|
394
402
|
if (!existsSync(SCHEMA_FILE))
|
|
395
403
|
return [];
|
|
@@ -411,8 +419,7 @@ function schemaFindings() {
|
|
|
411
419
|
node.declarationList.declarations.some((declaration) => ts.isIdentifier(declaration.name) &&
|
|
412
420
|
declaration.name.text === 'tables' &&
|
|
413
421
|
declaration.initializer !== undefined &&
|
|
414
|
-
|
|
415
|
-
declaration.initializer.expression.getText(source) === 'defineTables')) {
|
|
422
|
+
definesTables(declaration.initializer, source))) {
|
|
416
423
|
exportsTables = true;
|
|
417
424
|
}
|
|
418
425
|
if (ts.isCallExpression(node) &&
|
|
@@ -697,6 +704,11 @@ function themeScaleFindings() {
|
|
|
697
704
|
}
|
|
698
705
|
return findings;
|
|
699
706
|
}
|
|
707
|
+
function readsStarterRows(path, text, finding) {
|
|
708
|
+
return (finding.rule === 'import-from-root' &&
|
|
709
|
+
relative(ROOT, path) === 'seed.ts' &&
|
|
710
|
+
/^['"]\.\/starter\/rows\.ts['"]/.test(text.slice(finding.offset)));
|
|
711
|
+
}
|
|
700
712
|
const findingLines = [];
|
|
701
713
|
const violatedRules = new Set();
|
|
702
714
|
let scannedCount = 0;
|
|
@@ -707,6 +719,8 @@ for (const path of ownedFiles(ROOT)) {
|
|
|
707
719
|
continue;
|
|
708
720
|
scannedCount += 1;
|
|
709
721
|
for (const finding of scanner(text)) {
|
|
722
|
+
if (readsStarterRows(path, text, finding))
|
|
723
|
+
continue;
|
|
710
724
|
violatedRules.add(finding.rule);
|
|
711
725
|
findingLines.push(`${relative(ROOT, path)}:${lineAndColumn(text, finding.offset)}: pattern(${finding.rule})`);
|
|
712
726
|
}
|
package/dist/rules/lintPlugin.js
CHANGED
package/dist/schema.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { call, defineServices } from './services.ts';
|
|
2
2
|
export type { CallInput, CallReply, ServiceCall, ServiceMemory, Services } from './services.ts';
|
|
3
3
|
export { media } from './media.ts';
|
|
4
|
-
export
|
|
4
|
+
export { bookingTables } from './bookings.ts';
|
|
5
|
+
export type { BookingDay, BookingRequest, BookingRequestFields, BookingRules, BookingSlot, BookingStatus, BookingWindow } from './bookings.ts';
|
|
6
|
+
export type { Media, MediaImage, Starter, StarterPhoto } from './media.ts';
|
|
5
7
|
export { defineTables } from './table.ts';
|
|
6
8
|
export type { Access, Columns, Fields, Level, PublicRow, Row, Seed, Table, TableDefinition, TableOf, Tables, Value, Writes } from './table.ts';
|
package/dist/schema.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { BookingRequest } from '../bookings.ts';
|
|
3
|
+
import type { ServerBlock, ServerEnv } from './env.ts';
|
|
4
|
+
export type BookingsOptions = {
|
|
5
|
+
timezone: string;
|
|
6
|
+
};
|
|
7
|
+
export type BookingEmail = {
|
|
8
|
+
kind: 'requested' | 'accepted' | 'declined';
|
|
9
|
+
request: BookingRequest;
|
|
10
|
+
};
|
|
11
|
+
export declare function sendBookingEmail(c: Context<ServerEnv>, email: BookingEmail): Promise<void>;
|
|
12
|
+
export declare function bookings({ timezone }: BookingsOptions): ServerBlock;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { FULL, RULES_ID, UNAVAILABLE, bookingBlackout, bookingRequests, bookingRules, bookingServices, bookingTables, daysOf, isValidTimezone, rulesOf, rulesProblem, slotFor, takenKey, windowsOn } from "../bookings.js";
|
|
2
|
+
import { refused } from "../services.js";
|
|
3
|
+
import { serveServices } from "./services.js";
|
|
4
|
+
import { readRow, readRows } from "./tables.js";
|
|
5
|
+
const HOLDING_SQL = "status IN ('requested', 'accepted')";
|
|
6
|
+
export async function sendBookingEmail(c, email) {
|
|
7
|
+
if (!import.meta.env.DEV)
|
|
8
|
+
return;
|
|
9
|
+
const to = email.kind === 'requested' ? 'the owner' : email.request.email;
|
|
10
|
+
console.log(`Booking email (${email.kind}) to ${to} from ${new URL(c.req.url).host}: ${email.request.name}, ${email.request.date} ${email.request.window}`);
|
|
11
|
+
}
|
|
12
|
+
function requireAdmin(c) {
|
|
13
|
+
const viewer = c.get('viewer');
|
|
14
|
+
if (viewer === null)
|
|
15
|
+
throw refused(401, 'Signed out');
|
|
16
|
+
if (!viewer.isAdmin)
|
|
17
|
+
throw refused(403, 'forbidden');
|
|
18
|
+
}
|
|
19
|
+
async function readRules(d1) {
|
|
20
|
+
const [row, blackouts] = await Promise.all([
|
|
21
|
+
readRow(d1, bookingRules, RULES_ID),
|
|
22
|
+
readRows(d1, bookingTables.bookingBlackouts)
|
|
23
|
+
]);
|
|
24
|
+
const parsedRow = row === undefined ? undefined : bookingRules.row.parse(row);
|
|
25
|
+
return rulesOf(parsedRow, blackouts
|
|
26
|
+
.map((blackout) => bookingBlackout.parse(blackout))
|
|
27
|
+
.toSorted((a, b) => a.date.localeCompare(b.date)));
|
|
28
|
+
}
|
|
29
|
+
async function takenBetween(d1, first, last) {
|
|
30
|
+
const { results } = await d1
|
|
31
|
+
.prepare(`SELECT date, "window" AS slot, COUNT(*) AS count FROM booking_requests WHERE date >= ?1 AND date <= ?2 AND ${HOLDING_SQL} GROUP BY date, "window"`)
|
|
32
|
+
.bind(first, last)
|
|
33
|
+
.all();
|
|
34
|
+
return new Map(results.map((row) => [takenKey(row.date, row.slot), Number(row.count)]));
|
|
35
|
+
}
|
|
36
|
+
async function availabilityFor(c, timezone, first, last) {
|
|
37
|
+
const [rules, taken] = await Promise.all([
|
|
38
|
+
readRules(c.env.DB),
|
|
39
|
+
takenBetween(c.env.DB, first, last)
|
|
40
|
+
]);
|
|
41
|
+
return { rules, taken, now: new Date(), timezone };
|
|
42
|
+
}
|
|
43
|
+
async function readRequest(d1, id) {
|
|
44
|
+
const row = await readRow(d1, bookingRequests, id);
|
|
45
|
+
return row === undefined ? undefined : bookingRequests.row.parse(row);
|
|
46
|
+
}
|
|
47
|
+
async function decide(c, id, status) {
|
|
48
|
+
requireAdmin(c);
|
|
49
|
+
const from = status === 'accepted' ? ['requested'] : ['requested', 'accepted'];
|
|
50
|
+
const existing = await readRequest(c.env.DB, id);
|
|
51
|
+
if (existing === undefined)
|
|
52
|
+
throw refused(404, 'No such request');
|
|
53
|
+
if (!from.includes(existing.status)) {
|
|
54
|
+
throw refused(409, `This request is already ${existing.status}`);
|
|
55
|
+
}
|
|
56
|
+
const { meta } = await c.env.DB.prepare(`UPDATE booking_requests SET status = ?1 WHERE id = ?2 AND status IN (${from.map((item) => `'${item}'`).join(', ')})`)
|
|
57
|
+
.bind(status, id)
|
|
58
|
+
.run();
|
|
59
|
+
if (meta.changes === 0)
|
|
60
|
+
throw refused(409, 'This request just changed');
|
|
61
|
+
const decided = { ...existing, status };
|
|
62
|
+
await sendBookingEmail(c, { kind: status, request: decided });
|
|
63
|
+
return decided;
|
|
64
|
+
}
|
|
65
|
+
function monthRange(month) {
|
|
66
|
+
return { first: `${month}-01`, last: `${month}-31` };
|
|
67
|
+
}
|
|
68
|
+
export function bookings({ timezone }) {
|
|
69
|
+
if (!isValidTimezone(timezone)) {
|
|
70
|
+
throw new Error(`bookings({ timezone: '${timezone}' }) is not a time zone: use an IANA name such as 'America/Toronto'`);
|
|
71
|
+
}
|
|
72
|
+
const routes = serveServices(bookingServices, {
|
|
73
|
+
bookings: {
|
|
74
|
+
days: async (c, { month }) => {
|
|
75
|
+
const { first, last } = monthRange(month);
|
|
76
|
+
const facts = await availabilityFor(c, timezone, first, last);
|
|
77
|
+
return [...daysOf(month, facts)];
|
|
78
|
+
},
|
|
79
|
+
windows: async (c, { date }) => {
|
|
80
|
+
const facts = await availabilityFor(c, timezone, date, date);
|
|
81
|
+
return [...windowsOn(date, facts)];
|
|
82
|
+
},
|
|
83
|
+
request: async (c, fields) => {
|
|
84
|
+
const facts = await availabilityFor(c, timezone, fields.date, fields.date);
|
|
85
|
+
const slot = slotFor(fields.date, fields.window, facts);
|
|
86
|
+
if (slot === undefined)
|
|
87
|
+
throw refused(400, UNAVAILABLE);
|
|
88
|
+
if (slot.remaining === 0)
|
|
89
|
+
throw refused(409, FULL);
|
|
90
|
+
const id = crypto.randomUUID();
|
|
91
|
+
const status = facts.rules.autoAccept ? 'accepted' : 'requested';
|
|
92
|
+
const { meta } = await c.env.DB.prepare(`INSERT INTO booking_requests (id, offering, date, "window", name, email, phone, address, notes, status) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10 WHERE (SELECT COUNT(*) FROM booking_requests WHERE date = ?3 AND "window" = ?4 AND ${HOLDING_SQL}) < ?11`)
|
|
93
|
+
.bind(id, fields.offering, fields.date, fields.window, fields.name, fields.email, fields.phone ?? '', fields.address ?? '', fields.notes ?? '', status, slot.capacity)
|
|
94
|
+
.run();
|
|
95
|
+
if (meta.changes === 0)
|
|
96
|
+
throw refused(409, FULL);
|
|
97
|
+
const saved = await readRequest(c.env.DB, id);
|
|
98
|
+
if (saved === undefined)
|
|
99
|
+
throw new Error('The request was not saved');
|
|
100
|
+
await sendBookingEmail(c, {
|
|
101
|
+
kind: status === 'accepted' ? 'accepted' : 'requested',
|
|
102
|
+
request: saved
|
|
103
|
+
});
|
|
104
|
+
return saved;
|
|
105
|
+
},
|
|
106
|
+
requests: async (c) => {
|
|
107
|
+
requireAdmin(c);
|
|
108
|
+
const rows = await readRows(c.env.DB, bookingRequests);
|
|
109
|
+
return rows.map((row) => bookingRequests.row.parse(row));
|
|
110
|
+
},
|
|
111
|
+
accept: (c, { id }) => decide(c, id, 'accepted'),
|
|
112
|
+
decline: (c, { id }) => decide(c, id, 'declined'),
|
|
113
|
+
rules: async (c) => {
|
|
114
|
+
requireAdmin(c);
|
|
115
|
+
return readRules(c.env.DB);
|
|
116
|
+
},
|
|
117
|
+
saveRules: async (c, rules) => {
|
|
118
|
+
requireAdmin(c);
|
|
119
|
+
const problem = rulesProblem(rules);
|
|
120
|
+
if (problem !== undefined)
|
|
121
|
+
throw refused(400, problem);
|
|
122
|
+
const d1 = c.env.DB;
|
|
123
|
+
await d1.batch([
|
|
124
|
+
d1
|
|
125
|
+
.prepare('INSERT INTO booking_rules (id, windows, lead_time_hours, days_ahead, auto_accept) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT (id) DO UPDATE SET windows = excluded.windows, lead_time_hours = excluded.lead_time_hours, days_ahead = excluded.days_ahead, auto_accept = excluded.auto_accept')
|
|
126
|
+
.bind(RULES_ID, JSON.stringify(rules.windows), rules.leadTimeHours, rules.daysAhead, rules.autoAccept ? 1 : 0),
|
|
127
|
+
d1.prepare('DELETE FROM booking_blackouts'),
|
|
128
|
+
...rules.blackouts.map((blackout) => d1
|
|
129
|
+
.prepare('INSERT INTO booking_blackouts (id, date, reason) VALUES (?1, ?2, ?3)')
|
|
130
|
+
.bind(`blackout-${blackout.date}`, blackout.date, blackout.reason))
|
|
131
|
+
]);
|
|
132
|
+
return readRules(d1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
name: 'bookings',
|
|
138
|
+
tables: bookingTables,
|
|
139
|
+
openPaths: ['/api/bookings/'],
|
|
140
|
+
routes
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import type { Tables } from '../table.ts';
|
|
3
|
-
import type { ServerBindings, ServerEnv } from './env.ts';
|
|
3
|
+
import type { ServerBindings, ServerBlock, ServerEnv } from './env.ts';
|
|
4
4
|
import type { Pages } from './pages.ts';
|
|
5
|
-
export declare function createServer<Group extends Tables, Bindings extends ServerBindings = ServerBindings>({ tables, admins, routes, pages }: {
|
|
5
|
+
export declare function createServer<Group extends Tables, Bindings extends ServerBindings = ServerBindings>({ tables, admins, routes, pages, blocks }: {
|
|
6
6
|
tables: Group;
|
|
7
7
|
admins: readonly string[];
|
|
8
8
|
routes?: Hono<ServerEnv<Bindings>>;
|
|
9
9
|
pages?: Pages;
|
|
10
|
+
blocks?: readonly ServerBlock[];
|
|
10
11
|
}): import("hono/hono-base").HonoBase<ServerEnv, import("hono/types").BlankSchema | import("hono/types").MergeSchemaPath<(((({
|
|
11
12
|
"/api/health": {
|
|
12
13
|
$get: {
|
|
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { csrf } from 'hono/csrf';
|
|
3
3
|
import { adminEmails } from "../access.js";
|
|
4
4
|
import { appTitle } from "../appTitle.js";
|
|
5
|
+
import { blockWiringProblem } from "../blocks.js";
|
|
5
6
|
import { media } from "../media.js";
|
|
6
7
|
import { auth, devSignIn } from "./auth.js";
|
|
7
8
|
import { serveDatabase } from "./database.js";
|
|
@@ -23,14 +24,17 @@ function knowAdmins(admins) {
|
|
|
23
24
|
return next();
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
function attachViewer(optionalPaths) {
|
|
28
|
+
return (c, next) => viewerOf(c, next, [...VIEWER_OPTIONAL_PATHS, ...optionalPaths]);
|
|
29
|
+
}
|
|
30
|
+
async function viewerOf(c, next, optionalPaths) {
|
|
27
31
|
const bearer = bearerOf(c);
|
|
28
32
|
if (bearer !== undefined)
|
|
29
33
|
return attachPatternViewer(c, next, bearer);
|
|
30
34
|
if (PUBLIC_PATHS.some((path) => c.req.path.startsWith(path)))
|
|
31
35
|
return next();
|
|
32
36
|
const isOptional = c.req.path === APP_PATH ||
|
|
33
|
-
|
|
37
|
+
optionalPaths.some((path) => c.req.path.startsWith(path));
|
|
34
38
|
let pass = await readPass(c);
|
|
35
39
|
if (pass !== undefined && needsRecheck(pass)) {
|
|
36
40
|
const user = await renewSession(c.env.DB, pass.sessionId);
|
|
@@ -50,14 +54,17 @@ async function attachViewer(c, next) {
|
|
|
50
54
|
});
|
|
51
55
|
return next();
|
|
52
56
|
}
|
|
53
|
-
export function createServer({ tables, admins, routes, pages }) {
|
|
57
|
+
export function createServer({ tables, admins, routes, pages, blocks = [] }) {
|
|
58
|
+
const wiring = blockWiringProblem(tables, blocks, 'server');
|
|
59
|
+
if (wiring !== undefined)
|
|
60
|
+
throw new Error(wiring);
|
|
54
61
|
const authRoutes = import.meta.env.DEV ? auth.route('/', devSignIn) : auth;
|
|
55
62
|
const hasMedia = Object.values(tables).some((table) => table === media);
|
|
56
63
|
const server = new Hono()
|
|
57
64
|
.basePath('/api')
|
|
58
65
|
.use(csrf())
|
|
59
66
|
.use(knowAdmins(admins))
|
|
60
|
-
.use(attachViewer)
|
|
67
|
+
.use(attachViewer(blocks.flatMap((block) => block.openPaths)))
|
|
61
68
|
.get('/health', (c) => c.json({ ok: true }))
|
|
62
69
|
.route('/auth', authRoutes)
|
|
63
70
|
.get('/app', async (c) => c.json({ title: await readTitle(c.env.DB) }))
|
|
@@ -89,6 +96,8 @@ export function createServer({ tables, admins, routes, pages }) {
|
|
|
89
96
|
.route('/tables', serveTables(tables));
|
|
90
97
|
if (hasMedia)
|
|
91
98
|
server.post('/media', uploadMedia);
|
|
99
|
+
for (const block of blocks)
|
|
100
|
+
server.route('/', block.routes);
|
|
92
101
|
const own = routes;
|
|
93
102
|
const root = new Hono();
|
|
94
103
|
if (hasMedia)
|
package/dist/server/env.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { D1Database, Fetcher, R2Bucket } from '@cloudflare/workers-types';
|
|
2
|
+
import type { Hono } from 'hono';
|
|
2
3
|
import type { Viewer } from '../access.ts';
|
|
4
|
+
import type { Tables } from '../table.ts';
|
|
3
5
|
export type { Viewer };
|
|
4
6
|
export type ServerBindings = {
|
|
5
7
|
DB: D1Database;
|
|
@@ -15,3 +17,9 @@ export type ServerEnv<Bindings extends ServerBindings = ServerBindings> = {
|
|
|
15
17
|
admins: ReadonlySet<string>;
|
|
16
18
|
};
|
|
17
19
|
};
|
|
20
|
+
export type ServerBlock = {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly tables: Tables;
|
|
23
|
+
readonly openPaths: readonly string[];
|
|
24
|
+
readonly routes: Hono<ServerEnv>;
|
|
25
|
+
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createServer } from './createServer.ts';
|
|
2
|
-
export type { ServerBindings, ServerEnv, Viewer } from './env.ts';
|
|
2
|
+
export type { ServerBindings, ServerBlock, ServerEnv, Viewer } from './env.ts';
|
|
3
|
+
export { bookings } from './bookings.ts';
|
|
4
|
+
export type { BookingsOptions } from './bookings.ts';
|
|
3
5
|
export { seedSql, serveTables, sqlTable, sqlTables } from './tables.ts';
|
|
4
6
|
export { refused } from '../services.ts';
|
|
5
7
|
export { serveServices } from './services.ts';
|
package/dist/server/index.js
CHANGED
package/dist/server/media.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { Context } from 'hono';
|
|
2
2
|
import type { ServerEnv } from './env.ts';
|
|
3
|
+
export declare const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
4
|
+
export type PhotoFacts = {
|
|
5
|
+
key: string;
|
|
6
|
+
type: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
bytes: number;
|
|
10
|
+
};
|
|
11
|
+
export type PhotoProblem = {
|
|
12
|
+
problem: string;
|
|
13
|
+
status: 413 | 415;
|
|
14
|
+
};
|
|
15
|
+
export declare function photoFacts(buffer: ArrayBuffer): Promise<PhotoFacts | PhotoProblem>;
|
|
3
16
|
export declare function uploadMedia(c: Context<ServerEnv>): Promise<(Response & import("hono").TypedResponse<{
|
|
4
17
|
error: string;
|
|
5
18
|
}, 401, "json">) | (Response & import("hono").TypedResponse<{
|
|
@@ -8,9 +21,9 @@ export declare function uploadMedia(c: Context<ServerEnv>): Promise<(Response &
|
|
|
8
21
|
error: string;
|
|
9
22
|
}, 503, "json">) | (Response & import("hono").TypedResponse<{
|
|
10
23
|
error: string;
|
|
11
|
-
}, 413, "json">) | (Response & import("hono").TypedResponse<{
|
|
12
|
-
error: string;
|
|
13
24
|
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
14
25
|
error: string;
|
|
15
|
-
}, 415, "json">) | (Response & import("hono").TypedResponse<{},
|
|
26
|
+
}, 413 | 415, "json">) | (Response & import("hono").TypedResponse<{}, 200, "json">) | (Response & import("hono").TypedResponse<{
|
|
27
|
+
error: string;
|
|
28
|
+
}, 409, "json">) | (Response & import("hono").TypedResponse<{}, 201, "json">)>;
|
|
16
29
|
export declare function serveMedia(c: Context<ServerEnv>): Promise<Response>;
|
package/dist/server/media.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
1
2
|
import { drizzle } from 'drizzle-orm/d1';
|
|
2
3
|
import { MEDIA_LIMIT_BYTES, media, mediaPath } from "../media.js";
|
|
3
4
|
import { isHeic, readImageHeader } from "./imageHeader.js";
|
|
4
|
-
import { sqlTable } from "./tables.js";
|
|
5
|
+
import { column, sqlTable } from "./tables.js";
|
|
5
6
|
const FORM_OVERHEAD_BYTES = 64 * 1024;
|
|
6
7
|
const ALT_LIMIT = 500;
|
|
7
8
|
const KEY = /^images\/[0-9a-f]{16}\.(?:jpg|png|webp|gif|avif)$/;
|
|
8
|
-
const
|
|
9
|
+
const MEDIA_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
10
|
+
export const IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
9
11
|
const PLACEHOLDER = {
|
|
10
12
|
width: 24,
|
|
11
13
|
quality: 50,
|
|
@@ -18,6 +20,7 @@ const NO_ALT = 'Describe this photo for someone who can’t see it: alt is requi
|
|
|
18
20
|
const TOO_LARGE = 'Photos can be up to 10 MB.';
|
|
19
21
|
const HEIC = 'This is an iPhone HEIC photo, which browsers can’t show. Send it as a JPEG instead.';
|
|
20
22
|
const UNKNOWN_TYPE = 'Photos must be JPEG, PNG, WebP, GIF or AVIF.';
|
|
23
|
+
const BAD_ID = 'id must be 1 to 64 letters, numbers, hyphens or underscores.';
|
|
21
24
|
const target = sqlTable(media);
|
|
22
25
|
function hex(bytes) {
|
|
23
26
|
return [...new Uint8Array(bytes)]
|
|
@@ -55,6 +58,25 @@ async function placeholderOf(c, key) {
|
|
|
55
58
|
return null;
|
|
56
59
|
}
|
|
57
60
|
}
|
|
61
|
+
export async function photoFacts(buffer) {
|
|
62
|
+
if (buffer.byteLength > MEDIA_LIMIT_BYTES) {
|
|
63
|
+
return { problem: TOO_LARGE, status: 413 };
|
|
64
|
+
}
|
|
65
|
+
const bytes = new Uint8Array(buffer);
|
|
66
|
+
if (isHeic(bytes))
|
|
67
|
+
return { problem: HEIC, status: 415 };
|
|
68
|
+
const header = readImageHeader(bytes);
|
|
69
|
+
if (header === undefined)
|
|
70
|
+
return { problem: UNKNOWN_TYPE, status: 415 };
|
|
71
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
|
72
|
+
return {
|
|
73
|
+
key: `images/${hex(digest).slice(0, 16)}.${header.extension}`,
|
|
74
|
+
type: header.type,
|
|
75
|
+
width: header.width,
|
|
76
|
+
height: header.height,
|
|
77
|
+
bytes: bytes.byteLength
|
|
78
|
+
};
|
|
79
|
+
}
|
|
58
80
|
export async function uploadMedia(c) {
|
|
59
81
|
const viewer = c.get('viewer');
|
|
60
82
|
if (viewer === null)
|
|
@@ -71,6 +93,7 @@ export async function uploadMedia(c) {
|
|
|
71
93
|
const form = await c.req.formData().catch(() => undefined);
|
|
72
94
|
const file = form?.get('file');
|
|
73
95
|
const alt = form?.get('alt');
|
|
96
|
+
const id = form?.get('id') ?? null;
|
|
74
97
|
if (!(file instanceof File)) {
|
|
75
98
|
return c.json({ error: 'Send the photo as the form field "file".' }, 400);
|
|
76
99
|
}
|
|
@@ -80,31 +103,38 @@ export async function uploadMedia(c) {
|
|
|
80
103
|
if (alt.trim().length > ALT_LIMIT) {
|
|
81
104
|
return c.json({ error: `Keep alt to ${ALT_LIMIT} characters.` }, 400);
|
|
82
105
|
}
|
|
106
|
+
if (id !== null && (typeof id !== 'string' || !MEDIA_ID.test(id))) {
|
|
107
|
+
return c.json({ error: BAD_ID }, 400);
|
|
108
|
+
}
|
|
83
109
|
if (file.size > MEDIA_LIMIT_BYTES)
|
|
84
110
|
return c.json({ error: TOO_LARGE }, 413);
|
|
85
111
|
const buffer = await file.arrayBuffer();
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
return c.json({ error:
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
const facts = await photoFacts(buffer);
|
|
113
|
+
if ('problem' in facts)
|
|
114
|
+
return c.json({ error: facts.problem }, facts.status);
|
|
115
|
+
const database = drizzle(c.env.DB);
|
|
116
|
+
if (id !== null) {
|
|
117
|
+
const existing = await database
|
|
118
|
+
.select()
|
|
119
|
+
.from(target)
|
|
120
|
+
.where(eq(column(target, 'id'), id))
|
|
121
|
+
.get();
|
|
122
|
+
if (existing !== undefined) {
|
|
123
|
+
return Reflect.get(existing, 'key') === facts.key
|
|
124
|
+
? c.json(existing, 200)
|
|
125
|
+
: c.json({ error: `Another photo already has the id ${id}.` }, 409);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await bucket.put(facts.key, buffer, {
|
|
129
|
+
httpMetadata: { contentType: facts.type, cacheControl: IMMUTABLE }
|
|
96
130
|
});
|
|
97
|
-
const row = await
|
|
131
|
+
const row = await database
|
|
98
132
|
.insert(target)
|
|
99
133
|
.values({
|
|
100
|
-
id: crypto.randomUUID(),
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
placeholder: await placeholderOf(c, key),
|
|
105
|
-
alt: alt.trim(),
|
|
106
|
-
type: header.type,
|
|
107
|
-
bytes: bytes.byteLength
|
|
134
|
+
id: id ?? crypto.randomUUID(),
|
|
135
|
+
...facts,
|
|
136
|
+
placeholder: await placeholderOf(c, facts.key),
|
|
137
|
+
alt: alt.trim()
|
|
108
138
|
})
|
|
109
139
|
.returning()
|
|
110
140
|
.get();
|
|
@@ -113,6 +143,23 @@ export async function uploadMedia(c) {
|
|
|
113
143
|
function unquoted(etag) {
|
|
114
144
|
return etag.replace(/^W\//, '').replaceAll('"', '').trim();
|
|
115
145
|
}
|
|
146
|
+
async function fromLiveSite(key) {
|
|
147
|
+
const origin = import.meta.env['PATTERN_LIVE_ORIGIN'];
|
|
148
|
+
if (typeof origin !== 'string')
|
|
149
|
+
return undefined;
|
|
150
|
+
const response = await fetch(`${origin}${mediaPath(key)}`).catch(() => undefined);
|
|
151
|
+
if (response === undefined || !response.ok) {
|
|
152
|
+
await response?.body?.cancel();
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
return new Response(response.body, {
|
|
156
|
+
headers: {
|
|
157
|
+
'cache-control': IMMUTABLE,
|
|
158
|
+
'content-type': response.headers.get('content-type') ?? 'application/octet-stream',
|
|
159
|
+
'x-content-type-options': 'nosniff'
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
116
163
|
export async function serveMedia(c) {
|
|
117
164
|
const key = decodeURIComponent(c.req.path.slice('/media/'.length));
|
|
118
165
|
const bucket = c.env.MEDIA;
|
|
@@ -121,8 +168,10 @@ export async function serveMedia(c) {
|
|
|
121
168
|
}
|
|
122
169
|
const seen = c.req.header('if-none-match');
|
|
123
170
|
const object = await bucket.get(key, seen === undefined ? {} : { onlyIf: { etagDoesNotMatch: unquoted(seen) } });
|
|
124
|
-
if (object === null)
|
|
125
|
-
|
|
171
|
+
if (object === null) {
|
|
172
|
+
const live = import.meta.env.DEV ? await fromLiveSite(key) : undefined;
|
|
173
|
+
return live ?? c.text('No such photo', 404);
|
|
174
|
+
}
|
|
126
175
|
const headers = new Headers({
|
|
127
176
|
'cache-control': IMMUTABLE,
|
|
128
177
|
'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MEDIA_WRITE, TABLES_READ, verifiedAssertion } from "../patternAccess.js";
|
|
2
2
|
import { PATTERN_KEYS } from "./patternKeys.js";
|
|
3
3
|
const BEARER = /^bearer\s+/i;
|
|
4
4
|
const SCHEMA_PATH = '/api/schema';
|
|
5
5
|
const TABLES_PATH = '/api/tables/';
|
|
6
|
+
const MEDIA_PATH = '/api/media';
|
|
6
7
|
export function bearerOf(c) {
|
|
7
8
|
const header = c.req.header('authorization') ?? '';
|
|
8
9
|
return BEARER.test(header) ? header.replace(BEARER, '').trim() : undefined;
|
|
9
10
|
}
|
|
10
|
-
function
|
|
11
|
-
const { path } = c.req;
|
|
12
|
-
|
|
13
|
-
(path === SCHEMA_PATH || path.startsWith(TABLES_PATH)))
|
|
11
|
+
function scopeOf(c) {
|
|
12
|
+
const { path, method } = c.req;
|
|
13
|
+
if (method === 'GET' &&
|
|
14
|
+
(path === SCHEMA_PATH || path.startsWith(TABLES_PATH))) {
|
|
15
|
+
return TABLES_READ;
|
|
16
|
+
}
|
|
17
|
+
return method === 'POST' && path === MEDIA_PATH ? MEDIA_WRITE : undefined;
|
|
14
18
|
}
|
|
15
19
|
export async function attachPatternViewer(c, next, token) {
|
|
16
|
-
const
|
|
17
|
-
|
|
20
|
+
const scope = scopeOf(c);
|
|
21
|
+
const email = scope === undefined
|
|
22
|
+
? undefined
|
|
23
|
+
: await verifiedAssertion(token, {
|
|
18
24
|
hostname: new URL(c.req.url).hostname,
|
|
19
25
|
keys: PATTERN_KEYS,
|
|
20
|
-
now: Math.floor(Date.now() / 1000)
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
now: Math.floor(Date.now() / 1000),
|
|
27
|
+
scope
|
|
28
|
+
});
|
|
23
29
|
if (email === undefined)
|
|
24
30
|
return c.json({ error: 'Signed out' }, 401);
|
|
25
31
|
c.set('viewer', { userId: `pattern:${email}`, isAdmin: true });
|
package/dist/server/tables.d.ts
CHANGED
|
@@ -10,7 +10,9 @@ type SqlTable = SQLiteTableWithColumns<{
|
|
|
10
10
|
}>;
|
|
11
11
|
export declare function sqlTable(table: Table): SqlTable;
|
|
12
12
|
export declare function sqlTables<Group extends Tables>(tables: Group): Record<keyof Group, SqlTable>;
|
|
13
|
+
export declare function column(target: SqlTable, key: string): SqlTable["_"]["columns"][string];
|
|
13
14
|
export declare function seedSql<Group extends Tables>(tables: Group, seed: Seed<Group>): string;
|
|
15
|
+
export declare function starterSql<Group extends Tables>(tables: Group, rows: Seed<Group>): string;
|
|
14
16
|
export declare function readRows(d1: D1Database, table: Table): Promise<readonly Record<string, unknown>[]>;
|
|
15
17
|
export declare function readRow(d1: D1Database, table: Table, id: string): Promise<Record<string, unknown> | undefined>;
|
|
16
18
|
export declare function serveTables<Group extends Tables>(tables: Group): import("hono/hono-base").HonoBase<ServerEnv, {
|
package/dist/server/tables.js
CHANGED
|
@@ -47,7 +47,7 @@ export function sqlTable(table) {
|
|
|
47
47
|
export function sqlTables(tables) {
|
|
48
48
|
return Object.fromEntries(Object.entries(tables).map(([key, table]) => [key, sqlTable(table)]));
|
|
49
49
|
}
|
|
50
|
-
function column(target, key) {
|
|
50
|
+
export function column(target, key) {
|
|
51
51
|
const found = Reflect.get(target, key);
|
|
52
52
|
if (found === undefined)
|
|
53
53
|
throw new Error(`No column ${key}`);
|
|
@@ -99,6 +99,22 @@ export function seedSql(tables, seed) {
|
|
|
99
99
|
});
|
|
100
100
|
return [...users, ...rowsSql].join('\n');
|
|
101
101
|
}
|
|
102
|
+
export function starterSql(tables, rows) {
|
|
103
|
+
return Object.values(tables)
|
|
104
|
+
.flatMap((table) => {
|
|
105
|
+
const listed = seededRows(rows, table.name);
|
|
106
|
+
if (listed.length === 0 || table.owned)
|
|
107
|
+
return [];
|
|
108
|
+
const keys = ['id', 'createdAt', ...Object.keys(table.columns)];
|
|
109
|
+
const values = listed
|
|
110
|
+
.map((row) => `(${keys.map((key) => sqlLiteral((row[key] ?? null))).join(', ')})`)
|
|
111
|
+
.join(',\n ');
|
|
112
|
+
return [
|
|
113
|
+
`INSERT OR IGNORE INTO ${snakeCase(table.name)} (${keys.map(snakeCase).join(', ')}) VALUES\n ${values};`
|
|
114
|
+
];
|
|
115
|
+
})
|
|
116
|
+
.join('\n');
|
|
117
|
+
}
|
|
102
118
|
function selection(table, target) {
|
|
103
119
|
return Object.fromEntries([
|
|
104
120
|
'id',
|
package/dist/start.js
CHANGED
|
@@ -17,6 +17,8 @@ const TEST_SEED = 'patternSeed';
|
|
|
17
17
|
const INLINE_DATA_ID = 'pattern-data';
|
|
18
18
|
const TEST_ADMINS = 'patternAdmins';
|
|
19
19
|
const TEST_WORLD = 'patternWorld';
|
|
20
|
+
const TEST_TITLE = 'patternTitle';
|
|
21
|
+
const TEST_SIGNED_IN_AS = 'patternSignedInAs';
|
|
20
22
|
function testSeed() {
|
|
21
23
|
const seed = Reflect.get(window, TEST_SEED);
|
|
22
24
|
return typeof seed === 'object' && seed !== null ? seed : {};
|
|
@@ -27,6 +29,14 @@ function testAdmins() {
|
|
|
27
29
|
? admins.filter((email) => typeof email === 'string')
|
|
28
30
|
: [];
|
|
29
31
|
}
|
|
32
|
+
function testTitle() {
|
|
33
|
+
const title = Reflect.get(window, TEST_TITLE);
|
|
34
|
+
return typeof title === 'string' ? title : '';
|
|
35
|
+
}
|
|
36
|
+
function testSignedInAs() {
|
|
37
|
+
const email = Reflect.get(window, TEST_SIGNED_IN_AS);
|
|
38
|
+
return typeof email === 'string' ? email : null;
|
|
39
|
+
}
|
|
30
40
|
function readInlineData() {
|
|
31
41
|
const text = document.getElementById(INLINE_DATA_ID)?.textContent ?? '';
|
|
32
42
|
if (text === '')
|
|
@@ -58,7 +68,9 @@ export function startApp(App, { theme = [], icons = {}, onInteraction } = {}) {
|
|
|
58
68
|
const app = new App(router, {
|
|
59
69
|
api: browserApi(tables, inMemory ? testSeed() : {}, gate, {
|
|
60
70
|
inMemory,
|
|
71
|
+
signedInAs: inMemory ? testSignedInAs() : null,
|
|
61
72
|
admins: inMemory ? testAdmins() : [],
|
|
73
|
+
title: inMemory ? testTitle() : '',
|
|
62
74
|
...(services === undefined ? {} : { services }),
|
|
63
75
|
...(memory === undefined ? {} : { memory }),
|
|
64
76
|
world
|