akanjs 3.0.0 → 3.0.2-rc.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/CHANGELOG.md +3670 -0
- package/base/base.ts +1 -1
- package/client/cookie.ts +4 -6
- package/client/csrTypes.ts +2 -2
- package/common/Logger.ts +10 -0
- package/common/index.ts +0 -1
- package/common/routeConvention.ts +1 -9
- package/dictionary/base.dictionary.ts +7 -0
- package/dictionary/trans.ts +7 -1
- package/document/documentQuery.ts +6 -1
- package/document/index.ts +1 -0
- package/document/into.ts +15 -7
- package/document/liveRowScope.ts +79 -0
- package/fetch/client/fetchClient.ts +64 -12
- package/fetch/client/httpClient.ts +13 -4
- package/index.ts +22 -0
- package/package.json +1 -1
- package/server/akanOption.ts +20 -1
- package/server/akanServer.ts +5 -0
- package/server/cachePolicy.ts +12 -2
- package/server/crawler.ts +6 -0
- package/server/head.tsx +0 -4
- package/server/mcp/McpAuth.ts +3 -2
- package/server/mcp/McpExecutionContext.ts +15 -14
- package/server/mcp/McpRateLimiter.ts +18 -67
- package/server/proxy/webProxyRunner.ts +2 -0
- package/server/resolver/database.resolver.ts +28 -13
- package/server/resolver/signal.resolver.ts +75 -35
- package/server/robots.ts +1 -23
- package/server/routeTreeBuilder.ts +4 -2
- package/server/rscWorker.tsx +12 -24
- package/server/rscWorkerHost.ts +16 -7
- package/server/ssrFromRscRenderer.tsx +19 -15
- package/server/ssrTypes.ts +9 -0
- package/server/webRouter.ts +9 -2
- package/service/index.ts +1 -0
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
- package/service/predefinedAdaptor/sql/types.ts +14 -12
- package/service/rateLimiter.ts +80 -0
- package/signal/base.signal.ts +7 -6
- package/signal/endpointRateLimit.ts +159 -0
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +6 -5
- package/signal/serializer/fetch.serializer.ts +15 -4
- package/signal/signalContext.ts +148 -67
- package/signal/slice.ts +30 -9
- package/signal/types.ts +8 -0
- package/test/signalTestRuntime.ts +2 -1
- package/types/base/base.d.ts +1 -1
- package/types/client/cookie.d.ts +1 -1
- package/types/client/csrTypes.d.ts +2 -2
- package/types/common/Logger.d.ts +2 -0
- package/types/common/index.d.ts +1 -1
- package/types/common/routeConvention.d.ts +0 -2
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +5 -1
- package/types/document/index.d.ts +1 -0
- package/types/document/into.d.ts +7 -7
- package/types/document/liveRowScope.d.ts +9 -0
- package/types/fetch/client/fetchClient.d.ts +9 -1
- package/types/index.d.ts +32 -0
- package/types/server/akanOption.d.ts +7 -1
- package/types/server/cachePolicy.d.ts +2 -1
- package/types/server/crawler.d.ts +1 -0
- package/types/server/head.d.ts +0 -3
- package/types/server/mcp/McpExecutionContext.d.ts +4 -1
- package/types/server/mcp/McpRateLimiter.d.ts +2 -6
- package/types/server/rscWorkerHost.d.ts +3 -0
- package/types/server/ssrFromRscRenderer.d.ts +3 -1
- package/types/server/ssrTypes.d.ts +8 -0
- package/types/service/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
- package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
- package/types/service/rateLimiter.d.ts +24 -0
- package/types/signal/endpointRateLimit.d.ts +37 -0
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +33 -15
- package/types/signal/slice.d.ts +17 -5
- package/types/signal/types.d.ts +8 -0
- package/types/test/signalTestRuntime.d.ts +1 -1
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +4 -4
- package/types/ui/recipe/inputRecipe.d.ts +3 -3
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
- package/types/webkit/RenderFailure.d.ts +6 -0
- package/types/webkit/useFrameRuntime.d.ts +0 -1
- package/ui/Input.tsx +7 -1
- package/ui/recipe/buttonRecipe.ts +1 -0
- package/ui/recipe/inputRecipe.ts +13 -7
- package/ui/styles.css +1 -0
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
- package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
- package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
- package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
- package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/addons.ts +2 -1
- package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/exec.ts +11 -1
- package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
- package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
- package/vendor/use-agentic/CHANGELOG.md +663 -0
- package/webkit/CsrRouteTable.ts +0 -2
- package/webkit/RenderFailure.tsx +32 -0
- package/webkit/RenderLayer.tsx +22 -2
- package/webkit/useCsrValues.ts +21 -11
package/server/rscWorker.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
pushRequestFallback,
|
|
18
18
|
requestStorage,
|
|
19
19
|
setRequestFrameState,
|
|
20
|
+
setRequestTheme,
|
|
20
21
|
untrackedCookies,
|
|
21
22
|
untrackedRequest,
|
|
22
23
|
updateRequestPolicy,
|
|
@@ -36,12 +37,7 @@ import {
|
|
|
36
37
|
resolveRouteCacheStoreTtl,
|
|
37
38
|
shouldStoreRouteCache,
|
|
38
39
|
} from "./cachePolicy";
|
|
39
|
-
import {
|
|
40
|
-
createAkanLocaleAlternateHeadSnapshot,
|
|
41
|
-
mergeAkanHeadSnapshots,
|
|
42
|
-
renderAkanHeadSnapshot,
|
|
43
|
-
shouldRenderLocaleAlternates,
|
|
44
|
-
} from "./head";
|
|
40
|
+
import { createAkanLocaleAlternateHeadSnapshot, mergeAkanHeadSnapshots, renderAkanHeadSnapshot } from "./head";
|
|
45
41
|
import { LogForwarder } from "./logging/logForwarder";
|
|
46
42
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
47
43
|
import { RouteElementComposer } from "./routeElementComposer";
|
|
@@ -1223,7 +1219,7 @@ export class RscRenderer {
|
|
|
1223
1219
|
searchParams,
|
|
1224
1220
|
})
|
|
1225
1221
|
: { node: undefined };
|
|
1226
|
-
const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead
|
|
1222
|
+
const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead);
|
|
1227
1223
|
return (
|
|
1228
1224
|
<html lang={params.lang ?? getPathnameLocale(pathname, this.#i18n)} suppressHydrationWarning>
|
|
1229
1225
|
<head key="head">
|
|
@@ -1254,14 +1250,16 @@ export class RscRenderer {
|
|
|
1254
1250
|
basePath: this.#getBasePath(url),
|
|
1255
1251
|
});
|
|
1256
1252
|
setRequestFrameState(pathRoute.pageState);
|
|
1253
|
+
|
|
1254
|
+
const rootThemes = await Promise.all(pathRoute.renderRootLayouts.map((render) => render.getLayoutTheme?.()));
|
|
1255
|
+
const rootTheme = rootThemes.find((theme) => theme !== undefined);
|
|
1256
|
+
if (rootTheme) setRequestTheme(rootTheme);
|
|
1257
1257
|
const routeHead = await RouteElementComposer.resolveHeadWithSnapshot({
|
|
1258
1258
|
pathRoute,
|
|
1259
1259
|
params: match.params,
|
|
1260
1260
|
searchParams,
|
|
1261
1261
|
});
|
|
1262
|
-
const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead
|
|
1263
|
-
isSpecialRoute: pathRoute.isSpecialRoute,
|
|
1264
|
-
});
|
|
1262
|
+
const routeHeadSnapshot = this.#createRouteHeadSnapshot(url, routeHead);
|
|
1265
1263
|
const body = RouteElementComposer.compose({
|
|
1266
1264
|
pathRoute,
|
|
1267
1265
|
params: match.params,
|
|
@@ -1277,9 +1275,7 @@ export class RscRenderer {
|
|
|
1277
1275
|
{routeHeadSnapshot
|
|
1278
1276
|
? renderAkanHeadSnapshot(routeHeadSnapshot)
|
|
1279
1277
|
: (routeHead.node ?? this.#renderDefaultHead())}
|
|
1280
|
-
{
|
|
1281
|
-
? this.#renderLocaleAlternates(url)
|
|
1282
|
-
: null}
|
|
1278
|
+
{routeHeadSnapshot ? null : this.#renderLocaleAlternates(url)}
|
|
1283
1279
|
{this.#renderStylesheet(url.pathname)}
|
|
1284
1280
|
</head>
|
|
1285
1281
|
<body key="body">{body}</body>
|
|
@@ -1370,22 +1366,14 @@ export class RscRenderer {
|
|
|
1370
1366
|
params: match.params,
|
|
1371
1367
|
searchParams,
|
|
1372
1368
|
});
|
|
1373
|
-
return this.#createRouteHeadSnapshot(url, routeHead
|
|
1374
|
-
isSpecialRoute: match.pathRoute.isSpecialRoute,
|
|
1375
|
-
});
|
|
1369
|
+
return this.#createRouteHeadSnapshot(url, routeHead);
|
|
1376
1370
|
}
|
|
1377
1371
|
|
|
1378
|
-
#createRouteHeadSnapshot(
|
|
1379
|
-
url: URL,
|
|
1380
|
-
routeHead: ResolvedHead,
|
|
1381
|
-
options: { isSpecialRoute?: boolean },
|
|
1382
|
-
): ResolvedHead["headSnapshot"] {
|
|
1372
|
+
#createRouteHeadSnapshot(url: URL, routeHead: ResolvedHead): ResolvedHead["headSnapshot"] {
|
|
1383
1373
|
if (!routeHead.headSnapshot) return undefined;
|
|
1384
1374
|
return mergeAkanHeadSnapshots(
|
|
1385
1375
|
routeHead.headSnapshot,
|
|
1386
|
-
|
|
1387
|
-
? createAkanLocaleAlternateHeadSnapshot(this.#getLocaleAlternateLanguages(url))
|
|
1388
|
-
: undefined,
|
|
1376
|
+
createAkanLocaleAlternateHeadSnapshot(this.#getLocaleAlternateLanguages(url)),
|
|
1389
1377
|
);
|
|
1390
1378
|
}
|
|
1391
1379
|
|
package/server/rscWorkerHost.ts
CHANGED
|
@@ -452,13 +452,13 @@ export class RscWorker {
|
|
|
452
452
|
|
|
453
453
|
renderWithMeta(
|
|
454
454
|
req: Request,
|
|
455
|
-
options: { clientManifest?: ClientManifest; signal?: AbortSignal } = {},
|
|
455
|
+
options: { clientManifest?: ClientManifest; signal?: AbortSignal; clientIp?: string | null } = {},
|
|
456
456
|
): Promise<RscRenderResult> {
|
|
457
457
|
const requestId = crypto.randomUUID();
|
|
458
458
|
return createRscHostRenderStream({
|
|
459
459
|
setPending: (pending) => this.#pending.set(requestId, pending),
|
|
460
460
|
deletePending: () => this.#pending.delete(requestId),
|
|
461
|
-
sendRenderOrQueue: () => this.#sendRenderOrQueue(requestId, req, options.clientManifest),
|
|
461
|
+
sendRenderOrQueue: () => this.#sendRenderOrQueue(requestId, req, options.clientManifest, options.clientIp),
|
|
462
462
|
cancelRender: () => this.#cancelRender(requestId),
|
|
463
463
|
signal: options.signal,
|
|
464
464
|
onPendingChunkOverflow: () => {
|
|
@@ -851,14 +851,23 @@ export class RscWorker {
|
|
|
851
851
|
this.#pending.delete(requestId);
|
|
852
852
|
}
|
|
853
853
|
|
|
854
|
-
|
|
854
|
+
/** The page request's headers as the worker sees them, `x-real-ip` being only the address the host resolved. */
|
|
855
|
+
static workerHeaders(req: Request, clientIp: string | null | undefined): Record<string, string> {
|
|
856
|
+
const headers: Record<string, string> = {};
|
|
857
|
+
req.headers.forEach((value, key) => {
|
|
858
|
+
headers[key] = value;
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
delete headers["x-real-ip"];
|
|
862
|
+
if (clientIp) headers["x-real-ip"] = clientIp;
|
|
863
|
+
return headers;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
#sendRenderOrQueue(requestId: string, req: Request, clientManifest?: ClientManifest, clientIp?: string | null): void {
|
|
855
867
|
const send = () => {
|
|
856
868
|
if (!this.#pending.has(requestId)) return;
|
|
857
869
|
try {
|
|
858
|
-
const headers
|
|
859
|
-
req.headers.forEach((value, key) => {
|
|
860
|
-
headers[key] = value;
|
|
861
|
-
});
|
|
870
|
+
const headers = RscWorker.workerHeaders(req, clientIp);
|
|
862
871
|
this.#proc.send({ type: "render", requestId, url: req.url, method: req.method, headers, clientManifest });
|
|
863
872
|
} catch (err) {
|
|
864
873
|
this.#resolvePending(requestId, (p) =>
|
|
@@ -9,7 +9,7 @@ import { renderToReadableStream } from "react-dom/server.browser";
|
|
|
9
9
|
import { createFromNodeStream } from "react-server-dom-webpack/client.node";
|
|
10
10
|
import { parsePositiveInt } from "./cachePolicy";
|
|
11
11
|
import { concatBytes } from "./rscHttp";
|
|
12
|
-
import type { SsrChunkRegistryStats, SsrFromRscInput, SsrLateRedirect } from "./ssrTypes";
|
|
12
|
+
import type { SsrChunkRegistryStats, SsrDocumentOptions, SsrFromRscInput, SsrLateRedirect } from "./ssrTypes";
|
|
13
13
|
|
|
14
14
|
const DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES = 1024;
|
|
15
15
|
const DEFAULT_MAX_PENDING_INLINE_RSC_SCRIPTS = 32;
|
|
@@ -593,21 +593,13 @@ export class SsrFromRscRenderer {
|
|
|
593
593
|
const base = `${SsrFromRscRenderer.#clientBootstrap}${SsrFromRscRenderer.#prefixBootstrap()}`;
|
|
594
594
|
const bootstrap = input.extraBootstrapInline ? `${base}\n${input.extraBootstrapInline}` : base;
|
|
595
595
|
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
onError: (error) => SsrFromRscRenderer.#reportRenderError(error, input),
|
|
596
|
+
const renderHtml = async () =>
|
|
597
|
+
await SsrFromRscRenderer.renderDocument(await thenable, {
|
|
598
|
+
bootstrap,
|
|
599
|
+
waitForAllReady: input.waitForAllReady || process.env.AKAN_SSR_WAIT_FOR_ALL_READY === "1",
|
|
600
|
+
inlineBoundaries: input.inlineBoundaries,
|
|
601
|
+
onError: (error, phase) => SsrFromRscRenderer.#reportRenderError(error, input, phase),
|
|
602
602
|
});
|
|
603
|
-
if (waitForAllReady) await stream.allReady;
|
|
604
|
-
else {
|
|
605
|
-
SsrFromRscRenderer.holdPostShellErrors(stream, (error) =>
|
|
606
|
-
SsrFromRscRenderer.#reportRenderError(error, input, "post-shell"),
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
return stream;
|
|
610
|
-
};
|
|
611
603
|
const requestContext = input.requestStore ?? input.request;
|
|
612
604
|
const htmlStream =
|
|
613
605
|
requestContext && requestStorage ? await requestStorage.run(requestContext, renderHtml) : await renderHtml();
|
|
@@ -661,6 +653,18 @@ export class SsrFromRscRenderer {
|
|
|
661
653
|
};
|
|
662
654
|
}
|
|
663
655
|
|
|
656
|
+
static async renderDocument(root: ReactNode, options: SsrDocumentOptions): Promise<ReadableStream<Uint8Array>> {
|
|
657
|
+
const stream = await renderToReadableStream(root, {
|
|
658
|
+
bootstrapScriptContent: options.bootstrap,
|
|
659
|
+
|
|
660
|
+
...(options.inlineBoundaries ? { progressiveChunkSize: Number.POSITIVE_INFINITY } : {}),
|
|
661
|
+
onError: (error) => options.onError(error),
|
|
662
|
+
});
|
|
663
|
+
if (options.waitForAllReady || options.inlineBoundaries) await stream.allReady;
|
|
664
|
+
else SsrFromRscRenderer.holdPostShellErrors(stream, (error) => options.onError(error, "post-shell"));
|
|
665
|
+
return stream;
|
|
666
|
+
}
|
|
667
|
+
|
|
664
668
|
static holdPostShellErrors(stream: { allReady: Promise<void> }, report: (error: unknown) => void): void {
|
|
665
669
|
void stream.allReady.catch(report);
|
|
666
670
|
}
|
package/server/ssrTypes.ts
CHANGED
|
@@ -61,4 +61,13 @@ export interface SsrFromRscInput {
|
|
|
61
61
|
onCancel?: (reason?: unknown) => void;
|
|
62
62
|
/** Buffers until `stream.allReady` (`pageConfig.ssr: "block"`); `AKAN_SSR_WAIT_FOR_ALL_READY=1` forces it globally. */
|
|
63
63
|
waitForAllReady?: boolean;
|
|
64
|
+
/** Buffers like `waitForAllReady` and writes every Suspense boundary in place, for a reader that runs no script. */
|
|
65
|
+
inlineBoundaries?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SsrDocumentOptions {
|
|
69
|
+
bootstrap: string;
|
|
70
|
+
waitForAllReady?: boolean;
|
|
71
|
+
inlineBoundaries?: boolean;
|
|
72
|
+
onError: (error: unknown, phase?: string) => void;
|
|
64
73
|
}
|
package/server/webRouter.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { type AkanRequestStore, createRequestStore, parseCookieHeader } from "akanjs/fetch";
|
|
14
14
|
import type { AkanMetricsReport } from "akanjs/service";
|
|
15
15
|
import type { PagePromptSource } from "../signal/mcp/pagePrompt";
|
|
16
|
+
import { SignalContext } from "../signal/signalContext";
|
|
16
17
|
import {
|
|
17
18
|
type BuilderRpc,
|
|
18
19
|
type MergedManifest,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
shouldStoreRouteCache,
|
|
37
38
|
} from "./cachePolicy";
|
|
38
39
|
import { encodedFileResponse } from "./contentEncoding";
|
|
40
|
+
import { isCrawlerUserAgent } from "./crawler";
|
|
39
41
|
import { HMR_CLIENT_SCRIPT } from "./hmr/clientScript";
|
|
40
42
|
import { CSR_DEV_ROUTE_PREFIX, resolveDevCsrMode, SSR_DEV_DIRNAME, SSR_DEV_ROUTE_PREFIX } from "./hmr/csrDevManifest";
|
|
41
43
|
import { CsrDevShell } from "./hmr/csrDevShell";
|
|
@@ -501,6 +503,7 @@ export class WebRouter {
|
|
|
501
503
|
const result = await this.#rsc.renderWithMeta(rscReq, {
|
|
502
504
|
clientManifest: manifest.clientManifest,
|
|
503
505
|
signal: req.signal,
|
|
506
|
+
clientIp: SignalContext.clientIpOf(req),
|
|
504
507
|
});
|
|
505
508
|
if (result.type === "redirect")
|
|
506
509
|
return createRscRedirectResponse(result.location, result.method, result.status);
|
|
@@ -600,7 +603,8 @@ export class WebRouter {
|
|
|
600
603
|
try {
|
|
601
604
|
this.#requestStats.fullSsr += 1;
|
|
602
605
|
const manifest = await this.#ensureRoute(url);
|
|
603
|
-
const
|
|
606
|
+
const isCrawler = isCrawlerUserAgent(req.headers.get("user-agent"));
|
|
607
|
+
const htmlCacheDecision = this.#getHtmlCacheEntry(req, url, isCrawler);
|
|
604
608
|
const htmlCacheEntry = htmlCacheDecision.entry;
|
|
605
609
|
const cachedHtml = htmlCacheEntry ? this.#getCachedHtml(htmlCacheEntry.key) : null;
|
|
606
610
|
if (cachedHtml) {
|
|
@@ -611,6 +615,7 @@ export class WebRouter {
|
|
|
611
615
|
const rscResult = await this.#rsc.renderWithMeta(req, {
|
|
612
616
|
clientManifest: manifest.clientManifest,
|
|
613
617
|
signal: req.signal,
|
|
618
|
+
clientIp: SignalContext.clientIpOf(req),
|
|
614
619
|
});
|
|
615
620
|
if (rscResult.type === "redirect")
|
|
616
621
|
return Response.redirect(new URL(rscResult.location, url.origin), rscResult.status);
|
|
@@ -643,6 +648,7 @@ export class WebRouter {
|
|
|
643
648
|
},
|
|
644
649
|
lateControl: rscResult.lateControl,
|
|
645
650
|
waitForAllReady: rscResult.trace?.ssrBlocking ?? false,
|
|
651
|
+
inlineBoundaries: isCrawler,
|
|
646
652
|
onCancel: (reason: unknown) => {
|
|
647
653
|
rscResult.cancel(reason);
|
|
648
654
|
},
|
|
@@ -814,7 +820,7 @@ export class WebRouter {
|
|
|
814
820
|
}
|
|
815
821
|
}
|
|
816
822
|
|
|
817
|
-
#getHtmlCacheEntry(req: Request, url: URL): { entry: RouteCacheEntry | null; reason?: string } {
|
|
823
|
+
#getHtmlCacheEntry(req: Request, url: URL, isCrawler: boolean): { entry: RouteCacheEntry | null; reason?: string } {
|
|
818
824
|
|
|
819
825
|
if (this.#ssrDevShell) {
|
|
820
826
|
this.#htmlCacheBypass += 1;
|
|
@@ -824,6 +830,7 @@ export class WebRouter {
|
|
|
824
830
|
request: req,
|
|
825
831
|
url,
|
|
826
832
|
theme: WebRouter.#cookieValue(req, "theme"),
|
|
833
|
+
variant: isCrawler ? "crawler" : undefined,
|
|
827
834
|
defaultEnabled: this.#prodMode,
|
|
828
835
|
defaultAllow: this.#prodMode,
|
|
829
836
|
env: {
|
package/service/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./logging.adaptor";
|
|
|
10
10
|
export * from "./openaiDialect";
|
|
11
11
|
export * from "./openaiLlm";
|
|
12
12
|
export * from "./queue.adaptor";
|
|
13
|
+
export * from "./rateLimit.adaptor";
|
|
13
14
|
export * from "./role.adaptor";
|
|
14
15
|
export * from "./schedule.adaptor";
|
|
15
16
|
export * from "./searchIndex";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { adapt } from "../adapt";
|
|
2
|
+
import { type RateBudget, RateLimiter, type RateVerdict } from "../rateLimiter";
|
|
3
|
+
import { CacheAdaptorRole } from "./role.adaptor";
|
|
4
|
+
|
|
5
|
+
/** Counts a business key (a phone number, an email) across instances; unlike an endpoint's budget, no env turns it off. */
|
|
6
|
+
export class RateLimit extends adapt("rateLimit", ({ plug }) => ({
|
|
7
|
+
cache: plug(CacheAdaptorRole),
|
|
8
|
+
})) {
|
|
9
|
+
#limiter: RateLimiter | null = null;
|
|
10
|
+
|
|
11
|
+
async consume(key: string, budget: RateBudget): Promise<RateVerdict> {
|
|
12
|
+
this.#limiter ??= new RateLimiter(this.cache, { topic: "rateLimit" });
|
|
13
|
+
return await this.#limiter.consume(key, budget);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DEFAULT_VALUE, dayjs, FIELD_META } from "akanjs/base";
|
|
2
2
|
import { Logger } from "akanjs/common";
|
|
3
3
|
import { type ConstantModel, freshPrimitiveValue, getDefault } from "akanjs/constant";
|
|
4
|
+
import { Err } from "akanjs/dictionary";
|
|
4
5
|
import {
|
|
5
6
|
createDocumentId,
|
|
6
7
|
type DatabaseModel,
|
|
7
8
|
type DocumentQuery,
|
|
8
9
|
type DocumentSchema,
|
|
10
|
+
type DocumentScopeOptions,
|
|
9
11
|
type DocumentUpdate,
|
|
10
12
|
type DocumentUpdateInput,
|
|
11
13
|
type DocumentUpdateNode,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
documentQueryHelper,
|
|
14
16
|
isDocumentId,
|
|
15
17
|
isDocumentUpdateNode,
|
|
18
|
+
LiveRowScope,
|
|
16
19
|
NoDocumentError,
|
|
17
20
|
resolveDocumentUpdate,
|
|
18
21
|
sanitizeJson,
|
|
@@ -26,6 +29,7 @@ import {
|
|
|
26
29
|
type DocumentDatabaseOwner,
|
|
27
30
|
type DocumentRecord,
|
|
28
31
|
type FieldMap,
|
|
32
|
+
type FindIdOptions,
|
|
29
33
|
type FindManyOptions,
|
|
30
34
|
type FindOneOptions,
|
|
31
35
|
MODIFICATION_STATE,
|
|
@@ -59,6 +63,9 @@ interface DeclaredIndex {
|
|
|
59
63
|
next: string;
|
|
60
64
|
metaKey: string;
|
|
61
65
|
hash: string;
|
|
66
|
+
fields: Record<string, unknown>;
|
|
67
|
+
/** A key ordered neither 1 nor -1, as MongoDB's `"text"`: no SQL index here stands for it. */
|
|
68
|
+
unbuilt: boolean;
|
|
62
69
|
create: (name: string, concurrently: boolean) => string;
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -79,6 +86,8 @@ export class SqlDocumentStore {
|
|
|
79
86
|
static readonly #logger = new Logger("SqlDocumentStore");
|
|
80
87
|
/** The row a document was read or last written as. */
|
|
81
88
|
static readonly #storedRow = Symbol("akan.storedRow");
|
|
89
|
+
/** Set on a document read as removed, which only a `withRemoved` read hands out. */
|
|
90
|
+
static readonly #readRemoved = Symbol("akan.readRemoved");
|
|
82
91
|
|
|
83
92
|
constructor(
|
|
84
93
|
private readonly owner: DocumentDatabaseOwner,
|
|
@@ -102,7 +111,8 @@ export class SqlDocumentStore {
|
|
|
102
111
|
|
|
103
112
|
async #ensure() {
|
|
104
113
|
this.assertValidRefName(this.table);
|
|
105
|
-
const
|
|
114
|
+
const declared = await this.#declaredIndexes();
|
|
115
|
+
const indexes = declared.filter((index) => !index.unbuilt);
|
|
106
116
|
const createSchema = async () => {
|
|
107
117
|
const db = this.owner.getConnection();
|
|
108
118
|
const existed = !!this.owner.buildIndexConcurrently && !!(await this.owner.hasTable?.(this.table));
|
|
@@ -121,6 +131,7 @@ export class SqlDocumentStore {
|
|
|
121
131
|
`table:${this.table}`,
|
|
122
132
|
await descriptorHash({ table: this.table, columns: ["id", "createdAt", "updatedAt", "removedAt", "_doc"] }),
|
|
123
133
|
);
|
|
134
|
+
for (const index of declared) if (index.unbuilt) await this.#skipUnbuiltIndex(index, indexes);
|
|
124
135
|
const concurrent: (DeclaredIndex & { replace: boolean })[] = [];
|
|
125
136
|
for (const index of indexes) {
|
|
126
137
|
const stored = await this.owner.getMeta(index.metaKey);
|
|
@@ -157,16 +168,21 @@ export class SqlDocumentStore {
|
|
|
157
168
|
const declared = index.name ?? `${this.table}_${Object.keys(index.fields).map(toSafeRefName).join("_")}_${idx}`;
|
|
158
169
|
this.assertValidRefName(declared);
|
|
159
170
|
const name = this.dialect.indexName(declared);
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
const unbuilt = Object.values(index.fields).some((order) => order !== 1 && order !== -1);
|
|
172
|
+
const columns = unbuilt
|
|
173
|
+
? []
|
|
174
|
+
: Object.keys(index.fields).map((path) => ({
|
|
175
|
+
path,
|
|
176
|
+
expr: this.compiler.fieldExpr(path),
|
|
177
|
+
isArray: this.compiler.isArrayPath(path),
|
|
178
|
+
}));
|
|
165
179
|
return {
|
|
166
180
|
name,
|
|
167
181
|
next: this.dialect.indexName(`${declared}_next`),
|
|
168
182
|
metaKey: `index:${this.table}:${name}`,
|
|
169
183
|
hash: await descriptorHash(index),
|
|
184
|
+
fields: index.fields,
|
|
185
|
+
unbuilt,
|
|
170
186
|
create: (target: string, concurrently: boolean) =>
|
|
171
187
|
this.dialect.createIndex({
|
|
172
188
|
name: target,
|
|
@@ -180,6 +196,23 @@ export class SqlDocumentStore {
|
|
|
180
196
|
);
|
|
181
197
|
}
|
|
182
198
|
|
|
199
|
+
async #skipUnbuiltIndex(index: DeclaredIndex, built: DeclaredIndex[]) {
|
|
200
|
+
const dropped =
|
|
201
|
+
(await this.owner.getMeta(index.metaKey)) === index.hash && !built.some(({ name }) => name === index.name);
|
|
202
|
+
if (dropped) {
|
|
203
|
+
await this.owner.getConnection().execute(`DROP INDEX IF EXISTS ${quoteIdent(index.name)}`);
|
|
204
|
+
|
|
205
|
+
await this.owner.setMeta(index.metaKey, "");
|
|
206
|
+
}
|
|
207
|
+
const paths = Object.keys(index.fields);
|
|
208
|
+
SqlDocumentStore.#logger.warn(
|
|
209
|
+
`Index ${JSON.stringify(index.fields)} on ${this.table} is not built: only 1 and -1 order a key, and "text" is ` +
|
|
210
|
+
`MongoDB's text index, which as a B-tree would copy each whole value and fail long writes on Postgres. Search ` +
|
|
211
|
+
`${paths.join(", ")} through the full-text role, field(String, { text: "desc" }), or declare ` +
|
|
212
|
+
`{ ${paths[0]}: 1 } to look it up by equality.${dropped ? ` Dropped ${index.name}, which an earlier boot built from it.` : ""}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
183
216
|
async #replaceIndex(index: DeclaredIndex) {
|
|
184
217
|
const rebuild = async () => {
|
|
185
218
|
const db = this.owner.getConnection();
|
|
@@ -263,9 +296,22 @@ export class SqlDocumentStore {
|
|
|
263
296
|
}
|
|
264
297
|
|
|
265
298
|
async updateOneByQuery(query: DocumentQuery, update: DocumentUpdateInput, options: DocumentUpdateOptions = {}) {
|
|
299
|
+
if (options.upsert && options.withRemoved)
|
|
300
|
+
throw new Error(
|
|
301
|
+
`updateOneByQuery on "${this.table}" takes upsert or { withRemoved: true }, not both: a match would stay removed while a miss inserts a live row`,
|
|
302
|
+
);
|
|
303
|
+
return await this.#updateOne(query, update, options, "updateOneByQuery");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async #updateOne(
|
|
307
|
+
query: DocumentQuery,
|
|
308
|
+
update: DocumentUpdateInput,
|
|
309
|
+
options: DocumentUpdateOptions,
|
|
310
|
+
operation: string,
|
|
311
|
+
) {
|
|
266
312
|
const resolved = resolveDocumentUpdate(update);
|
|
267
313
|
const { assignments, params } = this.compiledUpdate(resolved);
|
|
268
|
-
const { where, params: whereParams } = this.writeQuery(query,
|
|
314
|
+
const { where, params: whereParams } = this.writeQuery(query, operation, options);
|
|
269
315
|
const subquery = `SELECT ${quoteIdent("id")} FROM ${quoteIdent(this.table)} WHERE ${where} ORDER BY ${this.compiler.orderBy()} LIMIT 1`;
|
|
270
316
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${quoteIdent("id")} IN (${subquery})`;
|
|
271
317
|
const changes = this.dialect.affectedRows(
|
|
@@ -282,9 +328,18 @@ export class SqlDocumentStore {
|
|
|
282
328
|
return { acknowledged: true, matchedCount: 0, modifiedCount: 1, upsertedId: inserted.id };
|
|
283
329
|
}
|
|
284
330
|
|
|
285
|
-
async updateManyByQuery(query: DocumentQuery, update: DocumentUpdateInput) {
|
|
331
|
+
async updateManyByQuery(query: DocumentQuery, update: DocumentUpdateInput, options: DocumentScopeOptions = {}) {
|
|
332
|
+
return await this.#updateMany(query, update, options, "updateManyByQuery");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async #updateMany(
|
|
336
|
+
query: DocumentQuery,
|
|
337
|
+
update: DocumentUpdateInput,
|
|
338
|
+
options: DocumentScopeOptions,
|
|
339
|
+
operation: string,
|
|
340
|
+
) {
|
|
286
341
|
const { assignments, params } = this.compiledUpdate(resolveDocumentUpdate(update));
|
|
287
|
-
const { where, params: whereParams } = this.writeQuery(query,
|
|
342
|
+
const { where, params: whereParams } = this.writeQuery(query, operation, options);
|
|
288
343
|
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${where}`;
|
|
289
344
|
const changes = this.dialect.affectedRows(
|
|
290
345
|
await this.owner
|
|
@@ -297,12 +352,12 @@ export class SqlDocumentStore {
|
|
|
297
352
|
|
|
298
353
|
async removeManyByQuery(query: DocumentQuery) {
|
|
299
354
|
|
|
300
|
-
return this
|
|
355
|
+
return await this.#updateMany(query, { removedAt: dayjs() }, {}, "removeManyByQuery");
|
|
301
356
|
}
|
|
302
357
|
|
|
303
358
|
async removeOneByQuery(query: DocumentQuery) {
|
|
304
359
|
|
|
305
|
-
return this
|
|
360
|
+
return await this.#updateOne(query, { removedAt: dayjs() }, {}, "removeOneByQuery");
|
|
306
361
|
}
|
|
307
362
|
|
|
308
363
|
private compiledUpdate(update: DocumentUpdate) {
|
|
@@ -339,20 +394,24 @@ export class SqlDocumentStore {
|
|
|
339
394
|
const rows = await this.prepareStmt(
|
|
340
395
|
`SELECT ${this.projectionSql(projection)} ${tail}`,
|
|
341
396
|
).all<ProjectedSqliteDocumentRow>(...args);
|
|
342
|
-
return rows.map((row) =>
|
|
397
|
+
return rows.map((row) =>
|
|
398
|
+
this.#readAs(this.hydrate(this.fromProjectedRow(row, projection), undefined, { track: false }), row),
|
|
399
|
+
);
|
|
343
400
|
}
|
|
344
401
|
const rows = await this.prepareStmt(`SELECT ${star} ${tail}`).all<SqliteDocumentRow>(...args);
|
|
345
|
-
return rows.map((row) =>
|
|
402
|
+
return rows.map((row) =>
|
|
403
|
+
this.#readAs(this.#withStoredRow(this.hydrate(this.fromRow(row), undefined, { track: false }), row), row),
|
|
404
|
+
);
|
|
346
405
|
}
|
|
347
406
|
|
|
348
407
|
async findIds(query?: DocumentQuery, options: Omit<FindManyOptions, "select"> = {}) {
|
|
349
|
-
const { tail, args } = this.listQuery(query, options);
|
|
408
|
+
const { tail, args } = this.listQuery(query, options, "findIds");
|
|
350
409
|
const rows = await this.prepareStmt(`SELECT ${quoteIdent(this.table)}."id" ${tail}`).all<{ id: string }>(...args);
|
|
351
410
|
return rows.map((row) => row.id);
|
|
352
411
|
}
|
|
353
412
|
|
|
354
|
-
private listQuery(query: DocumentQuery | undefined, options: FindManyOptions) {
|
|
355
|
-
const { where, params, joins } = this.safeQuery(query);
|
|
413
|
+
private listQuery(query: DocumentQuery | undefined, options: FindManyOptions, operation = "find") {
|
|
414
|
+
const { where, params, joins } = this.safeQuery(query, operation, options);
|
|
356
415
|
const limitValue = Number(options.limit ?? 0);
|
|
357
416
|
const skipValue = Number(options.skip ?? 0);
|
|
358
417
|
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
@@ -369,7 +428,7 @@ export class SqlDocumentStore {
|
|
|
369
428
|
return (await this.find(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
|
|
370
429
|
}
|
|
371
430
|
|
|
372
|
-
async findId(query?: DocumentQuery, options:
|
|
431
|
+
async findId(query?: DocumentQuery, options: FindIdOptions = {}) {
|
|
373
432
|
return (await this.findIds(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
|
|
374
433
|
}
|
|
375
434
|
|
|
@@ -385,12 +444,12 @@ export class SqlDocumentStore {
|
|
|
385
444
|
return doc;
|
|
386
445
|
}
|
|
387
446
|
|
|
388
|
-
async exists(query?: DocumentQuery) {
|
|
389
|
-
return this.findId(query);
|
|
447
|
+
async exists(query?: DocumentQuery, options: DocumentScopeOptions = {}) {
|
|
448
|
+
return this.findId(query, options);
|
|
390
449
|
}
|
|
391
450
|
|
|
392
|
-
async count(query?: DocumentQuery) {
|
|
393
|
-
const { where, params, joins } = this.safeQuery(query);
|
|
451
|
+
async count(query?: DocumentQuery, options: DocumentScopeOptions = {}) {
|
|
452
|
+
const { where, params, joins } = this.safeQuery(query, "count", options);
|
|
394
453
|
const row = await this.prepareStmt(
|
|
395
454
|
`SELECT count(*) as count FROM ${quoteIdent(this.table)}${this.joinSql(joins)} WHERE ${where}`,
|
|
396
455
|
).get<{ count: number }>(...this.joinParams(joins), ...params);
|
|
@@ -416,12 +475,27 @@ export class SqlDocumentStore {
|
|
|
416
475
|
return result;
|
|
417
476
|
}
|
|
418
477
|
|
|
419
|
-
private safeQuery(
|
|
478
|
+
private safeQuery(
|
|
479
|
+
query: DocumentQuery | undefined,
|
|
480
|
+
operation: string,
|
|
481
|
+
{ withRemoved = false }: DocumentScopeOptions,
|
|
482
|
+
) {
|
|
483
|
+
if (withRemoved) {
|
|
484
|
+
const compiled = this.compiler.compile(query ?? {});
|
|
485
|
+
|
|
486
|
+
if (compiled.joins.length) throw new Error(`q.search() cannot see removed rows on "${this.table}"`);
|
|
487
|
+
return compiled;
|
|
488
|
+
}
|
|
489
|
+
const conflict = LiveRowScope.conflictOf(query);
|
|
490
|
+
if (conflict)
|
|
491
|
+
throw new Error(
|
|
492
|
+
`${operation} on "${this.table}" can never match (${conflict}); ${operation.startsWith("remove") ? "a remove reaches live rows only" : "pass { withRemoved: true }"}`,
|
|
493
|
+
);
|
|
420
494
|
return this.compiler.compile(documentQueryHelper.all(documentQueryHelper.empty("removedAt"), query ?? {}));
|
|
421
495
|
}
|
|
422
496
|
|
|
423
|
-
private writeQuery(query: DocumentQuery | undefined, operation: string) {
|
|
424
|
-
const compiled = this.safeQuery(query);
|
|
497
|
+
private writeQuery(query: DocumentQuery | undefined, operation: string, options: DocumentScopeOptions) {
|
|
498
|
+
const compiled = this.safeQuery(query, operation, options);
|
|
425
499
|
if (compiled.joins.length)
|
|
426
500
|
throw new Error(`q.search() cannot be used in ${operation} on "${this.table}"; query-level writes take no join.`);
|
|
427
501
|
return compiled;
|
|
@@ -738,6 +812,19 @@ export class SqlDocumentStore {
|
|
|
738
812
|
return doc;
|
|
739
813
|
}
|
|
740
814
|
|
|
815
|
+
#readAs<Doc extends object>(doc: Doc, { removedAt }: { removedAt?: unknown }): Doc {
|
|
816
|
+
if (removedAt != null)
|
|
817
|
+
Object.defineProperty(doc, SqlDocumentStore.#readRemoved, { value: true, configurable: true });
|
|
818
|
+
return doc;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
#assertLiveRow(doc: DocumentRecord) {
|
|
822
|
+
if (!(doc as Record<symbol, unknown>)[SqlDocumentStore.#readRemoved]) return;
|
|
823
|
+
throw new Error(
|
|
824
|
+
`read-only: a removed ${this.table} is revived with updateById(id, { removedAt: null }, { withRemoved: true })`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
741
828
|
#assertImmutableUnchanged(prepared: DocumentRecord, originalData: DocumentRecord) {
|
|
742
829
|
this.#immutableKeys ??= Object.entries(this.database.doc[FIELD_META] as unknown as FieldMap)
|
|
743
830
|
.filter(([, fieldMeta]) => fieldMeta.getProps().immutable)
|
|
@@ -746,8 +833,10 @@ export class SqlDocumentStore {
|
|
|
746
833
|
const changed = this.#immutableKeys.filter((key) => jsonStr(prepared[key]) !== jsonStr(originalData[key]));
|
|
747
834
|
if (!changed.length) return;
|
|
748
835
|
|
|
749
|
-
throw new
|
|
750
|
-
|
|
836
|
+
throw new Err(
|
|
837
|
+
"base.error.immutableField",
|
|
838
|
+
{ field: changed.join(", ") },
|
|
839
|
+
{ details: { model: this.table, id: String(prepared.id) } },
|
|
751
840
|
);
|
|
752
841
|
}
|
|
753
842
|
|
|
@@ -887,13 +976,16 @@ export class SqlDocumentStore {
|
|
|
887
976
|
},
|
|
888
977
|
save: {
|
|
889
978
|
async value(this: DocumentRecord) {
|
|
890
|
-
|
|
979
|
+
if (!this.id) return store.create(this);
|
|
980
|
+
store.#assertLiveRow(this);
|
|
981
|
+
return store.update(this.id as string, store.#changesOf(this));
|
|
891
982
|
},
|
|
892
983
|
},
|
|
893
984
|
refresh: {
|
|
894
985
|
async value(this: DocumentRecord) {
|
|
895
986
|
const fresh = (await store.pickById(this.id as string)) as DocumentRecord & Record<symbol, StoredRow>;
|
|
896
987
|
Object.assign(this, fresh);
|
|
988
|
+
Reflect.deleteProperty(this, SqlDocumentStore.#readRemoved);
|
|
897
989
|
return store.#withStoredRow(this, fresh[SqlDocumentStore.#storedRow]);
|
|
898
990
|
},
|
|
899
991
|
},
|