lakebed 0.0.18 → 0.0.20

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/src/client.d.ts CHANGED
@@ -53,11 +53,49 @@ export type SignInWithGoogleProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElem
53
53
  children?: ComponentChildren;
54
54
  };
55
55
 
56
+ export type LakebedLocation = {
57
+ pathname: string;
58
+ search: string;
59
+ hash: string;
60
+ href: string;
61
+ };
62
+
63
+ export type NavigateOptions = {
64
+ replace?: boolean;
65
+ };
66
+
67
+ export type RouterProps = {
68
+ children?: ComponentChildren;
69
+ };
70
+
71
+ export type RoutesProps = {
72
+ children?: ComponentChildren;
73
+ };
74
+
75
+ export type RouteProps = {
76
+ path: string;
77
+ element: ComponentChildren;
78
+ };
79
+
80
+ export type LinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
81
+ to: string;
82
+ replace?: boolean;
83
+ children?: ComponentChildren;
84
+ };
85
+
56
86
  export function useAuth(): Auth;
57
87
  export function signInWithGoogle(options?: SignInWithGoogleOptions): Promise<SignInWithGoogleResult>;
58
88
  export function signOut(): void;
59
89
  export function getIdentity(): Identity;
60
90
  export function decodeIdentityClaims(idToken?: string): IdentityClaims | null;
61
91
  export function SignInWithGoogle(props?: SignInWithGoogleProps): JSX.Element;
92
+ export function Router(props?: RouterProps): JSX.Element;
93
+ export function Routes(props?: RoutesProps): JSX.Element | null;
94
+ export function Route(props: RouteProps): JSX.Element | null;
95
+ export function Link(props: LinkProps): JSX.Element;
96
+ export function navigate(to: string, options?: NavigateOptions): void;
97
+ export function useLocation(): LakebedLocation;
98
+ export function useNavigate(): (to: string, options?: NavigateOptions) => void;
99
+ export function useParams<TParams = Record<string, string | undefined>>(): TParams;
62
100
  export function useQuery<T>(name: string): T;
63
101
  export function useMutation<TArgs extends unknown[], TResult>(name: string): (...args: TArgs) => Promise<TResult>;
package/src/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { h } from "preact";
2
- import { useEffect, useState } from "preact/hooks";
1
+ import { createContext, h, toChildArray } from "preact";
2
+ import { useContext, useEffect, useState } from "preact/hooks";
3
3
 
4
4
  const DEFAULT_SHOO_BASE_URL = "https://shoo.dev";
5
5
  const AUTH_STORAGE_KEY = "lakebed_identity";
@@ -23,6 +23,14 @@ let authInitialized = false;
23
23
  let authResumeStarted = false;
24
24
  let refreshRequested = false;
25
25
 
26
+ const RouterContext = createContext(null);
27
+ const RouteContext = createContext({ params: {} });
28
+
29
+ function normalizeBasePathValue(value) {
30
+ const clean = String(value ?? "").replace(/\/+$/g, "");
31
+ return clean === "/" ? "" : clean;
32
+ }
33
+
26
34
  function toGuestName(name) {
27
35
  return (
28
36
  String(name ?? "local")
@@ -144,7 +152,7 @@ function send(message) {
144
152
  }
145
153
 
146
154
  function basePath() {
147
- return window.__LAKEBED_BASE_PATH__ ?? "";
155
+ return normalizeBasePathValue(window.__LAKEBED_BASE_PATH__ ?? "");
148
156
  }
149
157
 
150
158
  function authConfig() {
@@ -159,6 +167,214 @@ function currentRoute() {
159
167
  return `${window.location.pathname}${window.location.search}${window.location.hash}`;
160
168
  }
161
169
 
170
+ function appPathnameFromBrowserPathname(pathname) {
171
+ const base = basePath();
172
+ if (!base) {
173
+ return pathname || "/";
174
+ }
175
+
176
+ if (pathname === base) {
177
+ return "/";
178
+ }
179
+
180
+ if (pathname.startsWith(`${base}/`)) {
181
+ return pathname.slice(base.length) || "/";
182
+ }
183
+
184
+ return pathname || "/";
185
+ }
186
+
187
+ function currentAppLocation() {
188
+ if (typeof window === "undefined") {
189
+ return { hash: "", href: "/", pathname: "/", search: "" };
190
+ }
191
+
192
+ const pathname = appPathnameFromBrowserPathname(window.location.pathname);
193
+ const search = window.location.search;
194
+ const hash = window.location.hash;
195
+ return {
196
+ hash,
197
+ href: `${pathname}${search}${hash}`,
198
+ pathname,
199
+ search
200
+ };
201
+ }
202
+
203
+ function isExternalHref(value) {
204
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) || value.startsWith("//");
205
+ }
206
+
207
+ function browserHrefForAppHref(appHref) {
208
+ const url = new URL(appHref, "http://lakebed.local/");
209
+ const base = basePath();
210
+ const pathname = base ? `${base}${url.pathname === "/" ? "/" : url.pathname}` : url.pathname;
211
+ return `${pathname}${url.search}${url.hash}`;
212
+ }
213
+
214
+ function hrefForRoute(to) {
215
+ const value = String(to ?? "");
216
+ if (!value) {
217
+ return browserHrefForAppHref(currentAppLocation().href);
218
+ }
219
+
220
+ if (isExternalHref(value)) {
221
+ return value;
222
+ }
223
+
224
+ const current = currentAppLocation();
225
+ const resolved = new URL(value, `http://lakebed.local${current.href}`);
226
+ return browserHrefForAppHref(`${resolved.pathname}${resolved.search}${resolved.hash}`);
227
+ }
228
+
229
+ function emitLocationChange() {
230
+ window.dispatchEvent(new Event("lakebed:locationchange"));
231
+ }
232
+
233
+ export function navigate(to, options = {}) {
234
+ const href = hrefForRoute(to);
235
+ const parsed = new URL(href, window.location.href);
236
+
237
+ if (parsed.origin !== window.location.origin) {
238
+ window.location.assign(parsed.toString());
239
+ return;
240
+ }
241
+
242
+ const next = `${parsed.pathname}${parsed.search}${parsed.hash}`;
243
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
244
+ if (next === current) {
245
+ return;
246
+ }
247
+
248
+ if (options.replace) {
249
+ window.history.replaceState({}, "", next);
250
+ } else {
251
+ window.history.pushState({}, "", next);
252
+ }
253
+ emitLocationChange();
254
+ }
255
+
256
+ function useBrowserLocation() {
257
+ const [location, setLocation] = useState(currentAppLocation);
258
+
259
+ useEffect(() => {
260
+ function updateLocation() {
261
+ setLocation(currentAppLocation());
262
+ }
263
+
264
+ window.addEventListener("popstate", updateLocation);
265
+ window.addEventListener("lakebed:locationchange", updateLocation);
266
+ return () => {
267
+ window.removeEventListener("popstate", updateLocation);
268
+ window.removeEventListener("lakebed:locationchange", updateLocation);
269
+ };
270
+ }, []);
271
+
272
+ return location;
273
+ }
274
+
275
+ function normalizeMatchPath(path) {
276
+ const value = String(path ?? "/").trim();
277
+ if (value === "*" || value === "/*") {
278
+ return "*";
279
+ }
280
+
281
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
282
+ return withSlash.length > 1 ? withSlash.replace(/\/+$/g, "") : "/";
283
+ }
284
+
285
+ function pathSegments(path) {
286
+ const normalized = normalizeMatchPath(path);
287
+ if (normalized === "*" || normalized === "/") {
288
+ return [];
289
+ }
290
+
291
+ return normalized.replace(/^\/+|\/+$/g, "").split("/");
292
+ }
293
+
294
+ function decodeRouteSegment(value) {
295
+ try {
296
+ return decodeURIComponent(value);
297
+ } catch {
298
+ return value;
299
+ }
300
+ }
301
+
302
+ function matchRoutePath(pattern, pathname) {
303
+ const normalizedPattern = normalizeMatchPath(pattern);
304
+ if (normalizedPattern === "*") {
305
+ return { params: {} };
306
+ }
307
+
308
+ const patternSegments = pathSegments(normalizedPattern);
309
+ const pathnameSegments = pathSegments(pathname);
310
+ const params = {};
311
+
312
+ for (let index = 0; index < patternSegments.length; index += 1) {
313
+ const patternSegment = patternSegments[index];
314
+ const pathnameSegment = pathnameSegments[index];
315
+
316
+ if (patternSegment === "*") {
317
+ params["*"] = pathnameSegments.slice(index).map(decodeRouteSegment).join("/");
318
+ return { params };
319
+ }
320
+
321
+ if (pathnameSegment === undefined) {
322
+ return null;
323
+ }
324
+
325
+ if (patternSegment.startsWith(":")) {
326
+ const name = patternSegment.slice(1);
327
+ if (!name) {
328
+ return null;
329
+ }
330
+ params[name] = decodeRouteSegment(pathnameSegment);
331
+ continue;
332
+ }
333
+
334
+ if (patternSegment !== pathnameSegment) {
335
+ return null;
336
+ }
337
+ }
338
+
339
+ if (patternSegments.length !== pathnameSegments.length) {
340
+ return null;
341
+ }
342
+
343
+ return { params };
344
+ }
345
+
346
+ function routeChildren(children) {
347
+ const routes = [];
348
+ for (const child of toChildArray(children)) {
349
+ if (!child || typeof child !== "object") {
350
+ continue;
351
+ }
352
+
353
+ if (child.props?.path !== undefined) {
354
+ routes.push(child);
355
+ continue;
356
+ }
357
+
358
+ if (child.props?.children !== undefined) {
359
+ routes.push(...routeChildren(child.props.children));
360
+ }
361
+ }
362
+ return routes;
363
+ }
364
+
365
+ function shouldHandleLinkClick(event, target) {
366
+ return (
367
+ !event.defaultPrevented &&
368
+ event.button === 0 &&
369
+ !event.altKey &&
370
+ !event.ctrlKey &&
371
+ !event.metaKey &&
372
+ !event.shiftKey &&
373
+ (!target || target === "_self") &&
374
+ !event.currentTarget?.hasAttribute("download")
375
+ );
376
+ }
377
+
162
378
  function normalizeReturnTo(value) {
163
379
  if (!value) {
164
380
  return null;
@@ -746,6 +962,72 @@ export function SignInWithGoogle({
746
962
  );
747
963
  }
748
964
 
965
+ export function Router({ children } = {}) {
966
+ const location = useBrowserLocation();
967
+ return h(RouterContext.Provider, { value: { location, navigate } }, children);
968
+ }
969
+
970
+ export function Routes({ children } = {}) {
971
+ const location = useLocation();
972
+ const routes = routeChildren(children);
973
+ for (const route of routes) {
974
+ const match = matchRoutePath(route.props.path, location.pathname);
975
+ if (!match) {
976
+ continue;
977
+ }
978
+
979
+ return h(RouteContext.Provider, { value: match }, route.props.element ?? null);
980
+ }
981
+
982
+ return null;
983
+ }
984
+
985
+ export function Route() {
986
+ return null;
987
+ }
988
+
989
+ export function Link({ children, onClick, replace = false, target, to, ...props } = {}) {
990
+ const href = hrefForRoute(to);
991
+ return h(
992
+ "a",
993
+ {
994
+ ...props,
995
+ href,
996
+ onClick: (event) => {
997
+ onClick?.(event);
998
+ if (!shouldHandleLinkClick(event, target)) {
999
+ return;
1000
+ }
1001
+
1002
+ const parsed = new URL(href, window.location.href);
1003
+ if (parsed.origin !== window.location.origin) {
1004
+ return;
1005
+ }
1006
+
1007
+ event.preventDefault();
1008
+ navigate(to, { replace });
1009
+ },
1010
+ target
1011
+ },
1012
+ children
1013
+ );
1014
+ }
1015
+
1016
+ export function useLocation() {
1017
+ const context = useContext(RouterContext);
1018
+ const fallback = useBrowserLocation();
1019
+ return context?.location ?? fallback;
1020
+ }
1021
+
1022
+ export function useNavigate() {
1023
+ const context = useContext(RouterContext);
1024
+ return context?.navigate ?? navigate;
1025
+ }
1026
+
1027
+ export function useParams() {
1028
+ return useContext(RouteContext).params;
1029
+ }
1030
+
749
1031
  export function useQuery(name) {
750
1032
  const [value, setValue] = useState(queryValues.get(name) ?? []);
751
1033
 
package/src/server.d.ts CHANGED
@@ -49,11 +49,60 @@ export type ServerContext = {
49
49
  log: LogContext;
50
50
  };
51
51
 
52
+ export type EndpointRoute = {
53
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD" | (string & {});
54
+ path: `/${string}`;
55
+ };
56
+
57
+ export type EndpointHeaders = {
58
+ get(name: string): string | null;
59
+ has(name: string): boolean;
60
+ entries(): IterableIterator<[string, string]>;
61
+ };
62
+
63
+ export type EndpointRequest = {
64
+ method: string;
65
+ path: string;
66
+ url: string;
67
+ headers: EndpointHeaders;
68
+ query: URLSearchParams;
69
+ text(): Promise<string>;
70
+ json<T = unknown>(): Promise<T>;
71
+ bytes(): Promise<Uint8Array>;
72
+ };
73
+
74
+ export type EndpointResponse = {
75
+ kind: "response";
76
+ status: number;
77
+ headers?: Record<string, string>;
78
+ body?: string | Uint8Array | ArrayBuffer;
79
+ };
80
+
81
+ export type EndpointResponseOptions = {
82
+ status?: number;
83
+ headers?: Record<string, string>;
84
+ };
85
+
86
+ export type EndpointDefinition<TResult = EndpointResponse | string | unknown | void> = {
87
+ kind: "endpoint";
88
+ method: string;
89
+ path: string;
90
+ handler: (ctx: ServerContext, req: EndpointRequest) => TResult | Promise<TResult>;
91
+ };
92
+
52
93
  export function capsule<T>(definition: T): T;
53
94
  export function query<T>(handler: (ctx: ServerContext) => T): (ctx: ServerContext) => T;
54
95
  export function mutation<TArgs extends unknown[], TResult>(
55
96
  handler: (ctx: ServerContext, ...args: TArgs) => TResult
56
97
  ): (ctx: ServerContext, ...args: TArgs) => TResult;
98
+ export function endpoint<TResult>(
99
+ route: EndpointRoute,
100
+ handler: (ctx: ServerContext, req: EndpointRequest) => TResult | Promise<TResult>
101
+ ): EndpointDefinition<TResult>;
102
+ export function json(value: unknown, options?: EndpointResponseOptions): EndpointResponse;
103
+ export function text(value: unknown, options?: EndpointResponseOptions): EndpointResponse;
104
+ export function empty(options?: EndpointResponseOptions): EndpointResponse;
105
+ export function redirect(url: string, options?: EndpointResponseOptions): EndpointResponse;
57
106
  export function table(fields: Record<string, Field<unknown>>): TableDefinition;
58
107
  export function string(): Field<string>;
59
108
  export function boolean(): Field<boolean>;
package/src/server.js CHANGED
@@ -10,6 +10,59 @@ export function mutation(handler) {
10
10
  return handler;
11
11
  }
12
12
 
13
+ export function endpoint(route, handler) {
14
+ return {
15
+ handler,
16
+ kind: "endpoint",
17
+ method: String(route?.method ?? "").toUpperCase(),
18
+ path: String(route?.path ?? "")
19
+ };
20
+ }
21
+
22
+ function response(body, { headers = {}, status = 200 } = {}) {
23
+ return {
24
+ body,
25
+ headers,
26
+ kind: "response",
27
+ status
28
+ };
29
+ }
30
+
31
+ export function json(value, options = {}) {
32
+ return response(JSON.stringify(value ?? null), {
33
+ ...options,
34
+ headers: {
35
+ "Content-Type": "application/json; charset=utf-8",
36
+ ...(options.headers ?? {})
37
+ }
38
+ });
39
+ }
40
+
41
+ export function text(value, options = {}) {
42
+ return response(String(value ?? ""), {
43
+ ...options,
44
+ headers: {
45
+ "Content-Type": "text/plain; charset=utf-8",
46
+ ...(options.headers ?? {})
47
+ }
48
+ });
49
+ }
50
+
51
+ export function empty(options = {}) {
52
+ return response("", { status: 204, ...options });
53
+ }
54
+
55
+ export function redirect(url, options = {}) {
56
+ return response("", {
57
+ status: 302,
58
+ ...options,
59
+ headers: {
60
+ Location: String(url),
61
+ ...(options.headers ?? {})
62
+ }
63
+ });
64
+ }
65
+
13
66
  function field(kind) {
14
67
  return {
15
68
  kind,
@@ -312,6 +312,103 @@ function createBrokeredResponse(response) {
312
312
  };
313
313
  }
314
314
 
315
+ function endpointBodyToBase64(body) {
316
+ if (body === undefined || body === null) {
317
+ return "";
318
+ }
319
+ if (typeof body === "string") {
320
+ return nodeBuffer.from(body, "utf8").toString("base64");
321
+ }
322
+ if (body instanceof ArrayBuffer) {
323
+ return nodeBuffer.from(body).toString("base64");
324
+ }
325
+ if (ArrayBuffer.isView(body)) {
326
+ return nodeBuffer.from(body.buffer, body.byteOffset, body.byteLength).toString("base64");
327
+ }
328
+ return nodeBuffer.from(JSON.stringify(body ?? null), "utf8").toString("base64");
329
+ }
330
+
331
+ function endpointStatus(status, fallback = 200) {
332
+ const parsed = Number(status);
333
+ return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : fallback;
334
+ }
335
+
336
+ async function normalizeEndpointResponse(result) {
337
+ if (result === undefined || result === null) {
338
+ return { bodyBase64: "", headers: {}, status: 204 };
339
+ }
340
+
341
+ if (
342
+ result &&
343
+ typeof result === "object" &&
344
+ result.kind !== "response" &&
345
+ typeof result.arrayBuffer === "function" &&
346
+ typeof result.status === "number"
347
+ ) {
348
+ const body = nodeBuffer.from(await result.arrayBuffer());
349
+ return {
350
+ bodyBase64: body.toString("base64"),
351
+ headers: headersToObject(result.headers),
352
+ status: endpointStatus(result.status)
353
+ };
354
+ }
355
+
356
+ if (isPlainObject(result) && result.kind === "response") {
357
+ return {
358
+ bodyBase64: endpointBodyToBase64(result.body),
359
+ headers: headersToObject(result.headers),
360
+ status: endpointStatus(result.status)
361
+ };
362
+ }
363
+
364
+ if (typeof result === "string") {
365
+ return {
366
+ bodyBase64: endpointBodyToBase64(result),
367
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
368
+ status: 200
369
+ };
370
+ }
371
+
372
+ return {
373
+ bodyBase64: endpointBodyToBase64(result),
374
+ headers: { "Content-Type": "application/json; charset=utf-8" },
375
+ status: 200
376
+ };
377
+ }
378
+
379
+ function createEndpointRequest(request) {
380
+ const url = new URL(request.url ?? request.path ?? "/", "http://lakebed.local");
381
+ const body = nodeBuffer.from(request.bodyBase64 ?? "", "base64");
382
+ const headers = new Map(Object.entries(headersToObject(request.headers)).map(([key, value]) => [key.toLowerCase(), String(value)]));
383
+
384
+ return {
385
+ headers: {
386
+ entries() {
387
+ return headers.entries();
388
+ },
389
+ get(name) {
390
+ return headers.get(String(name).toLowerCase()) ?? null;
391
+ },
392
+ has(name) {
393
+ return headers.has(String(name).toLowerCase());
394
+ }
395
+ },
396
+ method: String(request.method ?? "GET").toUpperCase(),
397
+ path: request.path ?? url.pathname,
398
+ query: new URLSearchParams(url.searchParams),
399
+ url: url.href,
400
+ async bytes() {
401
+ return new Uint8Array(body);
402
+ },
403
+ async json() {
404
+ return JSON.parse(body.toString("utf8"));
405
+ },
406
+ async text() {
407
+ return body.toString("utf8");
408
+ }
409
+ };
410
+ }
411
+
315
412
  function brokeredFetch(input, init = {}) {
316
413
  if (!allowBrokeredFetch) {
317
414
  return Promise.reject(new Error("Outbound fetch is disabled for anonymous deploys."));
@@ -389,7 +486,8 @@ function sendError(error) {
389
486
  sendToParent?.({
390
487
  error: {
391
488
  message: error instanceof Error ? error.message : String(error),
392
- name: error instanceof Error ? error.name : "Error"
489
+ name: error instanceof Error ? error.name : "Error",
490
+ stack: error instanceof Error ? error.stack : undefined
393
491
  },
394
492
  ok: false,
395
493
  type: "source-runtime.result"
@@ -429,6 +527,21 @@ async function runSource(request) {
429
527
  return;
430
528
  }
431
529
 
530
+ if (request.op === "endpoint") {
531
+ const definition = app.endpoints?.[request.name];
532
+ const handler = definition?.handler ?? definition;
533
+ if (typeof handler !== "function") {
534
+ throw new Error(`Unknown endpoint: ${request.name}`);
535
+ }
536
+ const result = await handler(source.ctx, createEndpointRequest(request.endpointRequest ?? {}));
537
+ const response = await normalizeEndpointResponse(result);
538
+ sendResult({
539
+ operations: jsonSafe(source.operations),
540
+ response: jsonSafe(response)
541
+ });
542
+ return;
543
+ }
544
+
432
545
  throw new Error(`Unsupported source operation: ${request.op}`);
433
546
  }
434
547
 
@@ -493,6 +493,9 @@ async function brokeredFetch(request, options) {
493
493
  function workerError(payload) {
494
494
  const error = new Error(payload?.message ?? "Source runtime failed.");
495
495
  error.name = payload?.name ?? "SourceRuntimeError";
496
+ if (payload?.stack) {
497
+ error.stack = payload.stack;
498
+ }
496
499
  return error;
497
500
  }
498
501
 
@@ -557,6 +560,29 @@ export class ChildProcessSourceRuntime {
557
560
  }, mutationTransactionOptions(limits));
558
561
  }
559
562
 
563
+ async executeEndpoint({ artifact, auth, deployId, limits = DEFAULT_ANONYMOUS_LIMITS, name, request, state }) {
564
+ return state.transaction(deployId, async (tx) => {
565
+ const snapshot = await snapshotSourceState({ artifact, deployId, state: tx });
566
+ const response = await this.runWorker({
567
+ allowFetch: artifact.deployTarget === "claimed-source",
568
+ artifact,
569
+ auth,
570
+ endpointRequest: request,
571
+ env: snapshot.env,
572
+ name,
573
+ op: "endpoint",
574
+ rows: snapshot.rows
575
+ });
576
+ await applySourceOperations({
577
+ artifact,
578
+ deployId,
579
+ operations: response.operations ?? [],
580
+ tx
581
+ });
582
+ return response.response ?? { bodyBase64: "", headers: {}, status: 204 };
583
+ }, mutationTransactionOptions(limits));
584
+ }
585
+
560
586
  runWorker(request) {
561
587
  return new Promise((resolveRun, rejectRun) => {
562
588
  const child = fork(this.workerPath, [], {
@@ -603,6 +629,7 @@ export class ChildProcessSourceRuntime {
603
629
  if (message.ok) {
604
630
  finish(resolveRun, {
605
631
  operations: message.operations,
632
+ response: message.response,
606
633
  result: message.result
607
634
  });
608
635
  } else {
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.18";
1
+ export const LAKEBED_VERSION = "0.0.20";