mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e4f25cf11 | |||
| 4a5bdade8d | |||
| 047bb80a97 | |||
| d826cb23a3 | |||
| 23a9e7c278 | |||
| a9fe43130e | |||
| 3b7ec06703 | |||
| 55a124b519 | |||
| dc3fe330d7 | |||
| d541167fb3 | |||
| 7e8b357bbb | |||
| 521d8bc6ff | |||
| 19f1941544 | |||
| f9373bd0e6 | |||
| 5384738b67 | |||
| d82e28e6b1 | |||
| c38bc95b1d | |||
| 03d3b8e407 | |||
| a97a79f11e | |||
| 90b4de7093 | |||
| 81f3914e7f | |||
| 5163e2142e | |||
| 15ed075de3 | |||
| 4f7925dfd8 | |||
| 21e970cd8c | |||
| 40b70e7a20 | |||
| 9958dd7308 | |||
| fef77eea61 | |||
| ed41887d74 | |||
| 8ba4793160 | |||
| 65e17db2a2 | |||
| e21b5e5b55 | |||
| 2e6b95e484 | |||
| 4e31f48917 |
+169
-77
@@ -1,110 +1,202 @@
|
||||
# Functional Source License, Version 1.1, MIT Future License
|
||||
|
||||
## Abbreviation
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
FSL-1.1-MIT
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
## Notice
|
||||
1. Definitions.
|
||||
|
||||
Copyright 2025 Dennis Senn
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
## Terms and Conditions
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
### Licensor ("We")
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
### The Software
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
### License Grant
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
### Permitted Purpose
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
1. substitutes for the Software;
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
Permitted Purposes specifically include using the Software:
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
1. for your internal use and access;
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
2. for non-commercial education;
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
3. for non-commercial research; and
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
### Patents
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Redistribution
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
### Disclaimer
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
### Trademarks
|
||||
Copyright 2026 Dennis Senn
|
||||
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
trademarks, trade names, service marks or product names.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
## Grant of Future License
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the MIT license that is effective on the second anniversary of the date we make
|
||||
the Software available. On or after that date, you may use the Software under
|
||||
the MIT license, in which case the following will apply:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="@types/bun" />
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { getFileFromContext, isFile, isReadableStream } from "core/utils";
|
||||
import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils";
|
||||
import { MediaApi } from "media/api/MediaApi";
|
||||
import { assetsPath, assetsTmpPath } from "../helper";
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("MediaApi", () => {
|
||||
expect(isReadableStream(res.body)).toBe(true);
|
||||
expect(isReadableStream(res.res.body)).toBe(true);
|
||||
|
||||
const blob = await res.res.blob();
|
||||
const blob = (await res.res.blob()) as File;
|
||||
expect(isFile(blob)).toBe(true);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
expect(blob.type).toBe("image/png");
|
||||
@@ -113,7 +113,7 @@ describe("MediaApi", () => {
|
||||
const res = await api.getFileStream(name);
|
||||
expect(isReadableStream(res)).toBe(true);
|
||||
|
||||
const blob = await new Response(res).blob();
|
||||
const blob = (await new Response(res).blob()) as File;
|
||||
expect(isFile(blob)).toBe(true);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
expect(blob.type).toBe("image/png");
|
||||
@@ -162,4 +162,30 @@ describe("MediaApi", () => {
|
||||
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
|
||||
}
|
||||
});
|
||||
|
||||
it("should add overwrite query for entity upload", async (c) => {
|
||||
const call = mock(() => null);
|
||||
const hono = new Hono().post(
|
||||
"/api/media/entity/:entity/:id/:field",
|
||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
const { overwrite } = c.req.valid("query");
|
||||
expect(overwrite).toBe(true);
|
||||
call();
|
||||
return c.json({ ok: true });
|
||||
},
|
||||
);
|
||||
const api = new MediaApi(
|
||||
{
|
||||
upload_fetcher: hono.request,
|
||||
},
|
||||
hono.request,
|
||||
);
|
||||
const file = Bun.file(`${assetsPath}/image.png`);
|
||||
const res = await api.uploadToEntity("posts", 1, "cover", file as any, {
|
||||
overwrite: true,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(call).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
describe("PasswordStrategy", () => {
|
||||
it("should enforce provided minimum length", async () => {
|
||||
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
|
||||
|
||||
expect(strategy.verify("password")({} as any)).rejects.toThrow();
|
||||
expect(
|
||||
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp, type AuthResponse } from "../../src";
|
||||
import { auth } from "../../src/modules/middlewares";
|
||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||
import {
|
||||
mergeObject,
|
||||
randomString,
|
||||
secureRandomString,
|
||||
withDisabledConsole,
|
||||
} from "../../src/core/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -62,12 +68,12 @@ const configs = {
|
||||
},
|
||||
};
|
||||
|
||||
function createAuthApp() {
|
||||
function createAuthApp(config?: Partial<AppAuthSchema>) {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const app = createApp({
|
||||
connection: dummyConnection,
|
||||
config: {
|
||||
auth: configs.auth,
|
||||
auth: mergeObject(configs.auth, config ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,6 +138,16 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
|
||||
|
||||
return { res, data };
|
||||
},
|
||||
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
|
||||
const res = (await app.server.request("/api/auth/password/register", {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: body(user),
|
||||
})) as Response;
|
||||
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
|
||||
|
||||
return { res, data };
|
||||
},
|
||||
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||
const res = (await app.server.request("/api/auth/me", {
|
||||
method: "GET",
|
||||
@@ -245,4 +261,61 @@ describe("integration auth", () => {
|
||||
expect(await $fns.me()).toEqual({ user: null as any });
|
||||
}
|
||||
});
|
||||
|
||||
it("should register users with default role", async () => {
|
||||
const app = createAuthApp({ default_role_register: "guest" });
|
||||
await app.build();
|
||||
const $fns = fns(app);
|
||||
|
||||
// takes default role
|
||||
expect(
|
||||
await app
|
||||
.createUser({
|
||||
email: "test@bknd.io",
|
||||
password: "12345678",
|
||||
})
|
||||
.then((r) => r.role),
|
||||
).toBe("guest");
|
||||
|
||||
// throws error if role doesn't exist
|
||||
expect(
|
||||
app.createUser({
|
||||
email: "test@bknd.io",
|
||||
password: "12345678",
|
||||
role: "doesnt exist",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// takes role if provided
|
||||
expect(
|
||||
await app
|
||||
.createUser({
|
||||
email: "test2@bknd.io",
|
||||
password: "12345678",
|
||||
role: "admin",
|
||||
})
|
||||
.then((r) => r.role),
|
||||
).toBe("admin");
|
||||
|
||||
// registering with role is not allowed
|
||||
expect(
|
||||
await $fns
|
||||
.register({
|
||||
email: "test3@bknd.io",
|
||||
password: "12345678",
|
||||
role: "admin",
|
||||
})
|
||||
.then((r) => r.res.ok),
|
||||
).toBe(false);
|
||||
|
||||
// takes default role
|
||||
expect(
|
||||
await $fns
|
||||
.register({
|
||||
email: "test3@bknd.io",
|
||||
password: "12345678",
|
||||
})
|
||||
.then((r) => r.data.user.role),
|
||||
).toBe("guest");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { TAppMediaConfig } from "../../src/media/media-schema";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { assetsPath, assetsTmpPath } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import * as proto from "data/prototype";
|
||||
|
||||
beforeAll(() => {
|
||||
disableConsoleLog();
|
||||
@@ -128,4 +129,87 @@ describe("MediaController", () => {
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
await destFile.delete();
|
||||
});
|
||||
|
||||
test("entity upload with max_items and overwrite", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
media: mergeObject(
|
||||
{
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: assetsTmpPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
),
|
||||
data: {
|
||||
entities: {
|
||||
posts: proto
|
||||
.entity("posts", {
|
||||
title: proto.text(),
|
||||
cover: proto.medium(),
|
||||
})
|
||||
.toJSON(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
// create a post first
|
||||
const createRes = await app.server.request("/api/data/entity/posts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Test Post" }),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const { data: post } = (await createRes.json()) as any;
|
||||
|
||||
const file = Bun.file(path);
|
||||
const uploadedFiles: string[] = [];
|
||||
|
||||
// upload first file to entity (should succeed)
|
||||
const res1 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
expect(res1.status).toBe(201);
|
||||
const result1 = (await res1.json()) as any;
|
||||
uploadedFiles.push(result1.name);
|
||||
|
||||
// upload second file without overwrite (should fail - max_items reached)
|
||||
const res2 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
expect(res2.status).toBe(400);
|
||||
const result2 = (await res2.json()) as any;
|
||||
expect(result2.error).toContain("Max items");
|
||||
|
||||
// upload third file with overwrite=true (should succeed and delete old file)
|
||||
const res3 = await app.server.request(
|
||||
`/api/media/entity/posts/${post.id}/cover?overwrite=true`,
|
||||
{
|
||||
method: "POST",
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
expect(res3.status).toBe(201);
|
||||
const result3 = (await res3.json()) as any;
|
||||
uploadedFiles.push(result3.name);
|
||||
|
||||
// verify old file was deleted from storage
|
||||
const oldFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[0]);
|
||||
expect(await oldFile.exists()).toBe(false);
|
||||
|
||||
// verify new file exists
|
||||
const newFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[1]);
|
||||
expect(await newFile.exists()).toBe(true);
|
||||
|
||||
// cleanup
|
||||
await newFile.delete();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,4 +223,32 @@ describe("AppAuth", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("default role for registration must be a valid role", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "123456",
|
||||
},
|
||||
allow_register: true,
|
||||
roles: {
|
||||
guest: {
|
||||
is_default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.build();
|
||||
|
||||
const auth = app.module.auth;
|
||||
// doesn't allow invalid role
|
||||
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
|
||||
// allows valid role
|
||||
await auth.schema().patch("default_role_register", "guest");
|
||||
expect(auth.toJSON().default_role_register).toBe("guest");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-12
@@ -3,17 +3,7 @@ import c from "picocolors";
|
||||
import { formatNumber } from "bknd/utils";
|
||||
|
||||
const deps = Object.keys(pkg.dependencies);
|
||||
const external = [
|
||||
"jsonv-ts/*",
|
||||
"wrangler",
|
||||
"bknd",
|
||||
"bknd/*",
|
||||
"@vitejs/plugin-react",
|
||||
"vite",
|
||||
"@tailwindcss/vite",
|
||||
"@cloudflare/vite-plugin",
|
||||
...deps,
|
||||
];
|
||||
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/cli/index.ts"],
|
||||
@@ -21,7 +11,6 @@ const result = await Bun.build({
|
||||
outdir: "./dist/cli",
|
||||
env: "PUBLIC_*",
|
||||
minify: true,
|
||||
banner: `const __originalLog=console.log;console.log=(...o)=>{const n=o[0];"string"==typeof n&&n.includes("[dotenv@")||__originalLog.apply(console,o)};`,
|
||||
external,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
|
||||
+57
-5
@@ -2,6 +2,8 @@ import { $ } from "bun";
|
||||
import * as tsup from "tsup";
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
import c from "picocolors";
|
||||
import { watch as fsWatch } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const watch = args.includes("--watch");
|
||||
@@ -83,7 +85,8 @@ async function buildApi() {
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
// don't use tsup's broken watch, we'll handle it ourselves
|
||||
watch: false,
|
||||
define,
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
@@ -121,7 +124,7 @@ async function buildUi() {
|
||||
const base = {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
watch: false,
|
||||
define,
|
||||
external: [
|
||||
...external,
|
||||
@@ -180,7 +183,7 @@ async function buildUiElements() {
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
watch: false,
|
||||
define,
|
||||
entry: ["src/ui/elements/index.ts"],
|
||||
outDir: "dist/ui/elements",
|
||||
@@ -225,7 +228,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
||||
return {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
watch: false,
|
||||
entry: [`src/adapter/${adapter}/index.ts`],
|
||||
format: ["esm"],
|
||||
platform: "neutral",
|
||||
@@ -305,6 +308,11 @@ async function buildAdapters() {
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("sveltekit"),
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("node"),
|
||||
platform: "node",
|
||||
@@ -335,4 +343,48 @@ async function buildAdapters() {
|
||||
]);
|
||||
}
|
||||
|
||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
||||
async function buildAll() {
|
||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
||||
}
|
||||
|
||||
// initial build
|
||||
await buildAll();
|
||||
|
||||
// custom watcher since tsup's watch is broken in 8.3.5+
|
||||
if (watch) {
|
||||
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isBuilding = false;
|
||||
|
||||
const rebuild = async () => {
|
||||
if (isBuilding) return;
|
||||
isBuilding = true;
|
||||
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
||||
try {
|
||||
await buildAll();
|
||||
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
||||
} catch (e) {
|
||||
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
||||
}
|
||||
isBuilding = false;
|
||||
};
|
||||
|
||||
const debouncedRebuild = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(rebuild, 100);
|
||||
};
|
||||
|
||||
// watch src directory recursively
|
||||
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
|
||||
if (!filename) return;
|
||||
// ignore non-source files
|
||||
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
|
||||
return;
|
||||
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||
debouncedRebuild();
|
||||
});
|
||||
|
||||
// keep process alive
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
+10
-3
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.20.0-rc.1",
|
||||
"version": "0.20.0",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -80,7 +80,6 @@
|
||||
"@aws-sdk/client-s3": "^3.922.0",
|
||||
"@bluwy/giget-core": "^0.1.6",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@cloudflare/vite-plugin": "^1.15.3",
|
||||
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
||||
"@cloudflare/workers-types": "^4.20251014.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
@@ -143,7 +142,7 @@
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "3.0.9",
|
||||
"wouter": "^3.7.1",
|
||||
"wrangler": "^4.52.1"
|
||||
"wrangler": "^4.45.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hono/node-server": "^1.19.6"
|
||||
@@ -254,6 +253,11 @@
|
||||
"import": "./dist/adapter/astro/index.js",
|
||||
"require": "./dist/adapter/astro/index.js"
|
||||
},
|
||||
"./adapter/sveltekit": {
|
||||
"types": "./dist/types/adapter/sveltekit/index.d.ts",
|
||||
"import": "./dist/adapter/sveltekit/index.js",
|
||||
"require": "./dist/adapter/sveltekit/index.js"
|
||||
},
|
||||
"./adapter/aws": {
|
||||
"types": "./dist/types/adapter/aws/index.d.ts",
|
||||
"import": "./dist/adapter/aws/index.js",
|
||||
@@ -281,6 +285,7 @@
|
||||
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
||||
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||
}
|
||||
},
|
||||
@@ -310,6 +315,8 @@
|
||||
"remix",
|
||||
"react-router",
|
||||
"astro",
|
||||
"sveltekit",
|
||||
"svelte",
|
||||
"bun",
|
||||
"node"
|
||||
]
|
||||
|
||||
@@ -69,11 +69,6 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
||||
if (Connection.isConnection(config.connection)) {
|
||||
connection = config.connection;
|
||||
} else {
|
||||
if (connection) {
|
||||
$console.warn(
|
||||
"Connection is not a valid connection object, using default SQLite connection",
|
||||
);
|
||||
}
|
||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||
const conf = appConfig.connection ?? { url: "file:data.db" };
|
||||
connection = sqlite(conf) as any;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sveltekit.adapter";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { afterAll, beforeAll, describe } from "bun:test";
|
||||
import * as sveltekit from "./sveltekit.adapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("sveltekit adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: (c, a) => sveltekit.getApp(c as any, a ?? ({} as any)),
|
||||
makeHandler: (c, a) => (request: Request) =>
|
||||
sveltekit.serve(c as any, a ?? ({} as any))({ request }),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||
|
||||
type TSvelteKit = {
|
||||
request: Request;
|
||||
};
|
||||
|
||||
export type SvelteKitBkndConfig<Env> = Pick<RuntimeBkndConfig<Env>, "adminOptions">;
|
||||
|
||||
/**
|
||||
* Get bknd app instance
|
||||
* @param config - bknd configuration
|
||||
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
|
||||
*/
|
||||
export async function getApp<Env>(
|
||||
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
|
||||
args: Env,
|
||||
) {
|
||||
return await createRuntimeApp(config, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create request handler for hooks.server.ts
|
||||
* @param config - bknd configuration
|
||||
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
|
||||
*/
|
||||
export function serve<Env>(
|
||||
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
|
||||
args: Env,
|
||||
) {
|
||||
return async (fnArgs: TSvelteKit) => {
|
||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||
};
|
||||
}
|
||||
+29
-1
@@ -46,6 +46,22 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
to.strategies!.password!.enabled = true;
|
||||
}
|
||||
|
||||
if (to.default_role_register && to.default_role_register?.length > 0) {
|
||||
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
|
||||
|
||||
if (!valid_to_role) {
|
||||
const msg = `Default role for registration not found: ${to.default_role_register}`;
|
||||
// if changing to a new value
|
||||
if (from.default_role_register !== to.default_role_register) {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// resetting gracefully, since role doesn't exist anymore
|
||||
$console.warn(`${msg}, resetting to undefined`);
|
||||
to.default_role_register = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return to;
|
||||
}
|
||||
|
||||
@@ -82,6 +98,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
||||
jwt: this.config.jwt,
|
||||
cookie: this.config.cookie,
|
||||
default_role_register: this.config.default_role_register,
|
||||
});
|
||||
|
||||
this.registerEntities();
|
||||
@@ -171,10 +188,20 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
||||
async createUser({
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
...additional
|
||||
}: CreateUserPayload): Promise<DB["users"]> {
|
||||
if (!this.enabled) {
|
||||
throw new Error("Cannot create user, auth not enabled");
|
||||
}
|
||||
if (role) {
|
||||
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
|
||||
throw new Error(`Role "${role}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const strategy = "password" as const;
|
||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||
@@ -183,6 +210,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
const { data: created } = await mutator.insertOne({
|
||||
...(additional as any),
|
||||
role: role || this.config.default_role_register || undefined,
|
||||
email,
|
||||
strategy,
|
||||
strategy_value,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
InvalidSchemaError,
|
||||
transformObject,
|
||||
mcpTool,
|
||||
$console,
|
||||
} from "bknd/utils";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
|
||||
@@ -210,7 +211,7 @@ export class AuthController extends Controller {
|
||||
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
||||
|
||||
const getUser = async (params: { id?: string | number; email?: string }) => {
|
||||
let user: DB["users"] | undefined = undefined;
|
||||
let user: DB["users"] | undefined;
|
||||
if (params.id) {
|
||||
const { data } = await this.userRepo.findId(params.id);
|
||||
user = data;
|
||||
@@ -225,26 +226,33 @@ export class AuthController extends Controller {
|
||||
};
|
||||
|
||||
const roles = Object.keys(this.auth.config.roles ?? {});
|
||||
mcp.tool(
|
||||
"auth_user_create",
|
||||
{
|
||||
description: "Create a new user",
|
||||
inputSchema: s.object({
|
||||
email: s.string({ format: "email" }),
|
||||
password: s.string({ minLength: 8 }),
|
||||
role: s
|
||||
.string({
|
||||
enum: roles.length > 0 ? roles : undefined,
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||
try {
|
||||
const actions = this.auth.authenticator.strategy("password").getActions();
|
||||
if (actions.create) {
|
||||
const schema = actions.create.schema;
|
||||
mcp.tool(
|
||||
"auth_user_create",
|
||||
{
|
||||
description: "Create a new user",
|
||||
inputSchema: s.object({
|
||||
...schema.properties,
|
||||
role: s
|
||||
.string({
|
||||
enum: roles.length > 0 ? roles : undefined,
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||
|
||||
return c.json(await this.auth.createUser(params));
|
||||
},
|
||||
);
|
||||
return c.json(await this.auth.createUser(params));
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
$console.warn("error creating auth_user_create tool", e);
|
||||
}
|
||||
|
||||
mcp.tool(
|
||||
"auth_user_token",
|
||||
|
||||
@@ -51,6 +51,7 @@ export const authConfigSchema = $object(
|
||||
basepath: s.string({ default: "/api/auth" }),
|
||||
entity_name: s.string({ default: "users" }),
|
||||
allow_register: s.boolean({ default: true }).optional(),
|
||||
default_role_register: s.string().optional(),
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
strategies: $record(
|
||||
|
||||
@@ -74,6 +74,7 @@ export const jwtConfig = s.strictObject(
|
||||
export const authenticatorConfig = s.object({
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
default_role_register: s.string().optional(),
|
||||
});
|
||||
|
||||
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
||||
@@ -164,9 +165,13 @@ export class Authenticator<
|
||||
if (!("strategy_value" in profile)) {
|
||||
throw new InvalidConditionsException("Profile must have a strategy value");
|
||||
}
|
||||
if ("role" in profile) {
|
||||
throw new InvalidConditionsException("Role cannot be provided during registration");
|
||||
}
|
||||
|
||||
const user = await this.userPool.create(strategy.getName(), {
|
||||
...profile,
|
||||
role: this.config.default_role_register,
|
||||
strategy_value: profile.strategy_value,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const schema = s
|
||||
.object({
|
||||
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
||||
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
||||
minLength: s.number({ default: 8, minimum: 1 }).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -37,7 +38,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
||||
format: "email",
|
||||
}),
|
||||
password: s.string({
|
||||
minLength: 8, // @todo: this should be configurable
|
||||
minLength: this.config.minLength,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -65,12 +66,21 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
||||
return await bcryptCompare(compare, actual);
|
||||
}
|
||||
|
||||
return false;
|
||||
return actual === compare;
|
||||
}
|
||||
|
||||
verify(password: string) {
|
||||
return async (user: User) => {
|
||||
const compare = await this.compare(user?.strategy_value!, password);
|
||||
if (!user || !user.strategy_value) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
|
||||
$console.debug("PasswordStrategy: Invalid password", password);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
const compare = await this.compare(user.strategy_value, password);
|
||||
if (compare !== true) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
||||
import * as utils from "./utils";
|
||||
import { $console } from "core/utils";
|
||||
import { Project } from "./lib/Project";
|
||||
|
||||
export const dev: CliCommand = (program) =>
|
||||
withConfigOptions(program.command("dev"))
|
||||
.description("dev server")
|
||||
.addOption(new Option("--build", "build the project"))
|
||||
.action(action);
|
||||
|
||||
async function action(options: WithConfigOptions<{ build?: boolean }>) {
|
||||
console.log("options", options);
|
||||
const project = new Project({
|
||||
templatePath: utils.TEMPLATE_PATH,
|
||||
});
|
||||
|
||||
await project.init();
|
||||
|
||||
if (options.build) {
|
||||
await project.build();
|
||||
process.exit(0);
|
||||
}
|
||||
await project.listen();
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { RelativeFS } from "./RelativeFS";
|
||||
//import { $console } from "bknd/utils";
|
||||
import { type ViteDevServer, createServer, type UserConfig, createBuilder } from "vite";
|
||||
import { readdir, copyFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import { getRelativeDistPath } from "cli/utils/sys";
|
||||
import { injectMain } from "./vite/inject-main";
|
||||
import { devFsVitePlugin } from "adapter/cloudflare";
|
||||
|
||||
export type ProjectOptions = {
|
||||
userPath?: string;
|
||||
templatePath?: string;
|
||||
};
|
||||
|
||||
// @todo: add multiple public dirs
|
||||
// @todo: add npm install (first time)
|
||||
// @todo: add package.json
|
||||
export class Project {
|
||||
public userFs: RelativeFS;
|
||||
public templateFs: RelativeFS;
|
||||
private _server?: ViteDevServer;
|
||||
|
||||
constructor(public options: ProjectOptions) {
|
||||
this.userFs = new RelativeFS(options.userPath ?? process.cwd());
|
||||
this.templateFs = new RelativeFS(options.templatePath ?? "src/cli/commands/dev/template");
|
||||
}
|
||||
|
||||
get server() {
|
||||
if (!this._server) {
|
||||
throw new Error("Server not initialized");
|
||||
}
|
||||
return this._server!;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const source = this.templateFs.root;
|
||||
const destination = this.userFs.root;
|
||||
|
||||
// recursively copy all files and directories from source to dest
|
||||
const copyRecursive = async (src: string, dst: string) => {
|
||||
const entries = await readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const dstPath = path.join(dst, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.userFs.makeDir(path.relative(destination, dstPath));
|
||||
await copyRecursive(srcPath, dstPath);
|
||||
} else if (entry.isFile()) {
|
||||
const exists = await stat(dstPath)
|
||||
.then((s) => s.isFile())
|
||||
.catch(() => null);
|
||||
|
||||
// only copy everything from ".bknd" with override
|
||||
if (!exists || (dstPath.includes(".bknd") && !dstPath.includes("bknd-types.d.ts"))) {
|
||||
await copyFile(srcPath, dstPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await copyRecursive(source, destination);
|
||||
this._server = await createServer(this.getViteConfig());
|
||||
}
|
||||
|
||||
private getViteConfig(): UserConfig {
|
||||
return {
|
||||
clearScreen: false,
|
||||
publicDir: getRelativeDistPath() + "/static",
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
devFsVitePlugin({ configFile: ".bknd/bknd.config.ts" }) as any,
|
||||
cloudflare({
|
||||
configPath: this.userFs.path(".bknd/wrangler.json"),
|
||||
persistState: {
|
||||
path: this.userFs.path(".bknd/state"),
|
||||
},
|
||||
}),
|
||||
injectMain({
|
||||
appPath: "./src/App.tsx",
|
||||
rootId: "root",
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
minify: true,
|
||||
},
|
||||
resolve: {
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async build() {
|
||||
const builder = await createBuilder(this.getViteConfig());
|
||||
await builder.buildApp();
|
||||
}
|
||||
|
||||
async listen() {
|
||||
await this.server.listen();
|
||||
this.server.printUrls();
|
||||
this.server.bindCLIShortcuts({ print: true });
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
mkdir,
|
||||
stat,
|
||||
readFile as nodeReadFile,
|
||||
writeFile as nodeWriteFile,
|
||||
} from "node:fs/promises";
|
||||
import { getRootPath } from "cli/utils/sys";
|
||||
import path from "node:path";
|
||||
|
||||
export class RelativeFS {
|
||||
public root: string;
|
||||
|
||||
constructor(p: string) {
|
||||
this.root = path.resolve(getRootPath(), p);
|
||||
}
|
||||
|
||||
path(p: string) {
|
||||
return path.join(this.root, p);
|
||||
}
|
||||
|
||||
async hasFile(path: string) {
|
||||
try {
|
||||
const s = await stat(this.path(path));
|
||||
return s.isFile();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async hasDir(path: string) {
|
||||
try {
|
||||
const s = await stat(this.path(path));
|
||||
return s.isDirectory();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(p: string) {
|
||||
return await nodeReadFile(this.path(p), "utf-8");
|
||||
}
|
||||
|
||||
async writeFile(p: string, content: string) {
|
||||
return await nodeWriteFile(this.path(p), content);
|
||||
}
|
||||
|
||||
async makeDir(p: string) {
|
||||
return await mkdir(this.path(p), { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { PluginOption } from "vite";
|
||||
|
||||
export function injectMain(options?: { appPath?: string; rootId?: string }): PluginOption {
|
||||
const appPath = options?.appPath ?? "/App.tsx";
|
||||
const rootId = options?.rootId ?? "root";
|
||||
const publicId = "/@virtual/react-main.js";
|
||||
const internalId = "\0virtual-react-main.js";
|
||||
|
||||
return [
|
||||
{
|
||||
name: "bknd-virtual-react-entry",
|
||||
transformIndexHtml(html) {
|
||||
return {
|
||||
html,
|
||||
tags: [
|
||||
{
|
||||
tag: "script",
|
||||
injectTo: "body",
|
||||
attrs: {
|
||||
type: "module",
|
||||
src: publicId,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id === publicId) {
|
||||
return internalId;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
load(id) {
|
||||
if (id === internalId) {
|
||||
return `
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "${appPath}";
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
const container = document.getElementById("${rootId}");
|
||||
if (!container) {
|
||||
throw new Error("Cannot find #${rootId} element");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(
|
||||
React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(ClientProvider, null, React.createElement(App, null))
|
||||
)
|
||||
);
|
||||
`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { DB } from "bknd";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
|
||||
declare global {
|
||||
type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;
|
||||
type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;
|
||||
type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["bknd-types.d.ts"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
import config from "./bknd.config";
|
||||
|
||||
export default serve(config);
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "bknd-dev",
|
||||
"main": "./worker.ts",
|
||||
"compatibility_date": "2025-10-08",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
"directory": "../dist/client",
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": ["!/", "/admin*", "/api*", "!/assets/*"]
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development"
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB"
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "BUCKET"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bknd + Vite + Cloudflare + React + TS</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
import "./styles.css";
|
||||
|
||||
export default function App() {
|
||||
return <div>Hello World</div>;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import type { ServerEnv } from "bknd";
|
||||
|
||||
/**
|
||||
* Add custom routes to the API here. Base path is `/api`.
|
||||
*/
|
||||
export default new Hono<ServerEnv>().get("/", (c) => {
|
||||
// const app = c.var.app;
|
||||
// const api = app.getApi();
|
||||
return c.json({ message: "Hello, world!" });
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { AppEvents, DatabaseEvents, type EventManager } from "bknd";
|
||||
|
||||
export default function (emgr: EventManager) {
|
||||
// emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
||||
// console.log("Request received", event.params.request.url);
|
||||
// });
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { em, entity, text, boolean, number, datetime, json, jsonSchema, enumm } from "bknd";
|
||||
|
||||
export default em({
|
||||
// todos: entity("todos", {
|
||||
// title: text(),
|
||||
// done: boolean(),
|
||||
// }),
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./.bknd/tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
mkdir,
|
||||
stat,
|
||||
readFile as nodeReadFile,
|
||||
writeFile as nodeWriteFile,
|
||||
} from "node:fs/promises";
|
||||
import { getRootPath } from "cli/utils/sys";
|
||||
|
||||
export const TEMPLATE_PATH = "src/cli/commands/dev/template";
|
||||
export const currentDir = process.cwd();
|
||||
|
||||
export const fs = (dir: string) => {
|
||||
const PATH = path.resolve(getRootPath(), dir);
|
||||
return {
|
||||
PATH,
|
||||
path: (_path: string) => path.join(PATH, _path),
|
||||
hasFile: async (file: string) => {
|
||||
try {
|
||||
const s = await stat(path.join(PATH, file));
|
||||
return s.isFile();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
hasDir: async (_dir: string) => {
|
||||
try {
|
||||
const s = await stat(path.join(PATH, _dir));
|
||||
return s.isDirectory();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
readFile: (file: string) => nodeReadFile(path.join(PATH, file), "utf-8"),
|
||||
readJsonFile: async (file: string) =>
|
||||
JSON.parse(await nodeReadFile(path.join(PATH, file), "utf-8")),
|
||||
writeFile: (file: string, content: string) => nodeWriteFile(path.join(PATH, file), content),
|
||||
makeDir: (_newDir: string) => mkdir(path.join(PATH, _newDir)),
|
||||
};
|
||||
};
|
||||
@@ -9,4 +9,3 @@ export { types } from "./types";
|
||||
export { mcp } from "./mcp/mcp";
|
||||
export { sync } from "./sync";
|
||||
export { secrets } from "./secrets";
|
||||
export { dev } from "./dev/dev";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from "commander";
|
||||
import color from "picocolors";
|
||||
import * as commands from "./commands";
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export function isDebug(): boolean {
|
||||
try {
|
||||
// @ts-expect-error - this is a global variable in dev
|
||||
return is_toggled(__isDev);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +71,12 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
}
|
||||
|
||||
protected uploadFile<T extends FileUploadedEventData>(
|
||||
body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
|
||||
body: BodyInit,
|
||||
opts?: {
|
||||
filename?: string;
|
||||
path?: TInput;
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
query?: Record<string, any>;
|
||||
},
|
||||
): FetchPromise<ResponseObject<T>> {
|
||||
const headers = {
|
||||
@@ -102,14 +103,22 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
headers,
|
||||
};
|
||||
if (opts?.path) {
|
||||
return this.post(opts.path, body, init);
|
||||
return this.request<T>(opts.path, opts?.query, {
|
||||
...init,
|
||||
body,
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
if (!name || name.length === 0) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
return this.post<T>(opts?.path ?? ["upload", name], body, init);
|
||||
return this.request<T>(opts?.path ?? ["upload", name], opts?.query, {
|
||||
...init,
|
||||
body,
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async upload<T extends FileUploadedEventData>(
|
||||
@@ -119,6 +128,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
path?: TInput;
|
||||
fetcher?: ApiFetcher;
|
||||
query?: Record<string, any>;
|
||||
} = {},
|
||||
) {
|
||||
if (item instanceof Request || typeof item === "string") {
|
||||
@@ -144,7 +154,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
});
|
||||
}
|
||||
|
||||
return this.uploadFile<T>(item, opts);
|
||||
return this.uploadFile<T>(item as BodyInit, opts);
|
||||
}
|
||||
|
||||
async uploadToEntity(
|
||||
@@ -155,11 +165,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
opts?: {
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
fetcher?: typeof fetch;
|
||||
overwrite?: boolean;
|
||||
},
|
||||
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
|
||||
const query = opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : undefined;
|
||||
return this.upload(item, {
|
||||
...opts,
|
||||
path: ["entity", entity, id, field],
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export function emailOTP({
|
||||
"/login",
|
||||
jsc(
|
||||
"json",
|
||||
s.object({
|
||||
s.strictObject({
|
||||
email: s.string({ format: "email" }),
|
||||
code: s.string({ minLength: 1 }).optional(),
|
||||
}),
|
||||
@@ -213,7 +213,7 @@ export function emailOTP({
|
||||
),
|
||||
jsc("query", s.object({ redirect: s.string().optional() })),
|
||||
async (c) => {
|
||||
const { email, code } = c.req.valid("json");
|
||||
const { email, code, ...rest } = c.req.valid("json");
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
// throw if user exists
|
||||
@@ -232,6 +232,7 @@ export function emailOTP({
|
||||
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
|
||||
|
||||
const user = await app.createUser({
|
||||
...rest,
|
||||
email,
|
||||
password: randomString(32, true),
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ export function AuthForm({
|
||||
</Group>
|
||||
<Group>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Password name="password" required minLength={8} />
|
||||
<Password name="password" required minLength={1} />
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -474,6 +474,7 @@ type SectionHeaderAccordionItemProps = {
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const SectionHeaderAccordionItem = ({
|
||||
@@ -483,6 +484,7 @@ export const SectionHeaderAccordionItem = ({
|
||||
ActiveIcon = IconChevronUp,
|
||||
children,
|
||||
renderHeaderRight,
|
||||
scrollContainerRef,
|
||||
}: SectionHeaderAccordionItemProps) => (
|
||||
<div
|
||||
style={{ minHeight: 49 }}
|
||||
@@ -493,6 +495,8 @@ export const SectionHeaderAccordionItem = ({
|
||||
: "flex-initial cursor-pointer hover:bg-primary/5",
|
||||
)}
|
||||
>
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: . */}
|
||||
{/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2",
|
||||
@@ -501,14 +505,12 @@ export const SectionHeaderAccordionItem = ({
|
||||
>
|
||||
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
|
||||
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex grow" />
|
||||
{renderHeaderRight?.({ open })}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-y-scroll transition-all",
|
||||
open ? " flex-grow" : "h-0 opacity-0",
|
||||
)}
|
||||
ref={scrollContainerRef}
|
||||
className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -518,14 +520,25 @@ export const SectionHeaderAccordionItem = ({
|
||||
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||
routePattern,
|
||||
identifier,
|
||||
renderHeaderRight,
|
||||
...props
|
||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
|
||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle" | "renderHeaderRight"> & {
|
||||
renderHeaderRight?: (props: { open: boolean; active: boolean }) => React.ReactNode;
|
||||
// it's optional because it could be provided using the context
|
||||
routePattern?: string;
|
||||
identifier: string;
|
||||
}) => {
|
||||
const { active, toggle } = useRoutePathState(routePattern, identifier);
|
||||
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
|
||||
return (
|
||||
<SectionHeaderAccordionItem
|
||||
{...props}
|
||||
open={active}
|
||||
toggle={toggle}
|
||||
renderHeaderRight={
|
||||
renderHeaderRight && ((props) => renderHeaderRight?.({ open: props.open, active }))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { SegmentedControl } from "@mantine/core";
|
||||
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
TbDatabase,
|
||||
@@ -24,34 +24,35 @@ import { useLocation } from "wouter";
|
||||
import { NavLink } from "./AppShell";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { getVersion } from "core/env";
|
||||
import { getVersion, isDebug } from "core/env";
|
||||
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
|
||||
import { useAppShellAdminOptions } from "ui/options";
|
||||
|
||||
export function HeaderNavigation() {
|
||||
const [location, navigate] = useLocation();
|
||||
const { config } = useBknd();
|
||||
|
||||
const items: {
|
||||
label: string;
|
||||
href: string;
|
||||
Icon: any;
|
||||
Icon?: any;
|
||||
exact?: boolean;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
/*{
|
||||
label: "Base",
|
||||
href: "#",
|
||||
exact: true,
|
||||
Icon: TbLayoutDashboard,
|
||||
disabled: true,
|
||||
tooltip: "Coming soon"
|
||||
},*/
|
||||
{ label: "Data", href: "/data", Icon: TbDatabase },
|
||||
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
|
||||
{ label: "Media", href: "/media", Icon: TbPhoto },
|
||||
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 },
|
||||
];
|
||||
|
||||
if (isDebug() || Object.keys(config.flows?.flows ?? {}).length > 0) {
|
||||
items.push({ label: "Flows", href: "/flows", Icon: TbHierarchy2 });
|
||||
}
|
||||
|
||||
if (config.server.mcp.enabled) {
|
||||
items.push({ label: "MCP", href: "/tools/mcp", Icon: McpIcon });
|
||||
}
|
||||
|
||||
const activeItem = items.find((item) =>
|
||||
item.exact ? location === item.href : location.startsWith(item.href),
|
||||
);
|
||||
|
||||
@@ -378,11 +378,7 @@ function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
const Policy = ({
|
||||
permission,
|
||||
}: {
|
||||
permission: TPermission;
|
||||
}) => {
|
||||
const Policy = ({ permission }: { permission: TPermission }) => {
|
||||
const { value } = useDerivedFieldContext("", ({ value }) => ({
|
||||
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
|
||||
}));
|
||||
@@ -503,22 +499,24 @@ const CustomFieldWrapper = ({
|
||||
className: "max-w-none",
|
||||
}}
|
||||
position="bottom-end"
|
||||
target={() =>
|
||||
typeof schema.content === "object" ? (
|
||||
<JsonViewer
|
||||
className="w-auto max-w-120 bg-background pr-3 text-sm"
|
||||
json={schema.content}
|
||||
title={schema.name}
|
||||
expand={5}
|
||||
/>
|
||||
) : (
|
||||
<CodePreview
|
||||
code={schema.content}
|
||||
lang="typescript"
|
||||
className="w-auto max-w-120 bg-background p-3 text-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
target={() => (
|
||||
<div className="w-auto max-w-[80vw] md:max-w-120 bg-background overflow-scroll">
|
||||
{typeof schema.content === "object" ? (
|
||||
<JsonViewer
|
||||
className="pr-3 text-sm"
|
||||
json={schema.content}
|
||||
title={schema.name}
|
||||
expand={5}
|
||||
/>
|
||||
) : (
|
||||
<CodePreview
|
||||
code={schema.content}
|
||||
lang="typescript"
|
||||
className="p-3 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
|
||||
{autoFormatString(schema.name)}
|
||||
|
||||
@@ -15,7 +15,12 @@ import { useBkndWindowContext } from "bknd/client";
|
||||
import ToolsRoutes from "./tools";
|
||||
|
||||
// @ts-ignore
|
||||
const TestRoutes = lazy(() => import("./test"));
|
||||
let TestRoutes: any;
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
TestRoutes = lazy(() => import("./test"));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
export function Routes({
|
||||
BkndWrapper,
|
||||
@@ -43,11 +48,13 @@ export function Routes({
|
||||
<Route path="/" nest>
|
||||
<Root>
|
||||
<Switch>
|
||||
<Route path="/test*" nest>
|
||||
<Suspense fallback={null}>
|
||||
<TestRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
{TestRoutes && (
|
||||
<Route path="/test*" nest>
|
||||
<Suspense fallback={null}>
|
||||
<TestRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
|
||||
@@ -49,19 +49,21 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
try {
|
||||
const user_entity = config.entity_name ?? "users";
|
||||
const entities = _s.config.data.entities ?? {};
|
||||
console.log("entities", entities, user_entity);
|
||||
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
|
||||
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
|
||||
.filter(Boolean);
|
||||
|
||||
if (user_fields.length > 0) {
|
||||
console.log("user_fields", user_fields);
|
||||
_schema.properties.jwt.properties.fields.items.enum = user_fields;
|
||||
_schema.properties.jwt.properties.fields.uniqueItems = true;
|
||||
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
|
||||
}
|
||||
} catch (e) {}
|
||||
console.log("_s", _s);
|
||||
|
||||
const roles = Object.keys(config.roles ?? {});
|
||||
if (roles.length > 0) {
|
||||
_schema.properties.default_role_register.enum = roles;
|
||||
}
|
||||
} catch (_e) {}
|
||||
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
||||
/* if (_s.permissions) {
|
||||
roleSchema.properties.permissions.items.enum = _s.permissions;
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function ToolsRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/" component={ToolsIndex} />
|
||||
<Route path="/mcp" component={ToolsMcp} />
|
||||
<Route path="/mcp*" component={ToolsMcp} nest />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,13 @@ import { Empty } from "ui/components/display/Empty";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||
import { Route, Switch } from "wouter";
|
||||
|
||||
export default function ToolsMcp() {
|
||||
useBrowserTitle(["MCP UI"]);
|
||||
|
||||
const { config } = useBknd();
|
||||
const feature = useMcpStore((state) => state.feature);
|
||||
const setFeature = useMcpStore((state) => state.setFeature);
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
|
||||
const mcpPath = config.server.mcp.path;
|
||||
|
||||
@@ -29,51 +28,57 @@ export default function ToolsMcp() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow max-w-screen">
|
||||
<AppShell.SectionHeader>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<McpIcon />
|
||||
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
||||
MCP UI
|
||||
</AppShell.SectionHeaderTitle>
|
||||
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
||||
<TbWorld />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||
{window.location.origin + mcpPath}
|
||||
</span>
|
||||
<RoutePathStateProvider path={"/:type?"} defaultIdentifier="tools">
|
||||
<div className="flex flex-col flex-grow max-w-screen">
|
||||
<AppShell.SectionHeader>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<McpIcon />
|
||||
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
||||
MCP UI
|
||||
</AppShell.SectionHeaderTitle>
|
||||
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
||||
<TbWorld />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||
{window.location.origin + mcpPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell.SectionHeader>
|
||||
</AppShell.SectionHeader>
|
||||
|
||||
<div className="flex h-full">
|
||||
<AppShell.Sidebar>
|
||||
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Resources"
|
||||
open={feature === "resources"}
|
||||
toggle={() => setFeature("resources")}
|
||||
>
|
||||
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
||||
<i>Resources</i>
|
||||
</div>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
</AppShell.Sidebar>
|
||||
{feature === "tools" && <Tools.Content />}
|
||||
|
||||
{!content && (
|
||||
<Empty title="No tool selected" description="Please select a tool to continue.">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => openSidebar()}
|
||||
className="block md:hidden"
|
||||
<div className="flex grow h-full">
|
||||
<AppShell.Sidebar>
|
||||
<Tools.Sidebar />
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
title="Resources"
|
||||
identifier="resources"
|
||||
>
|
||||
Open Tools
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
||||
<i>Resources</i>
|
||||
</div>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
</AppShell.Sidebar>
|
||||
|
||||
<Switch>
|
||||
<Route path="/tools/:toolName?" component={Tools.Content} />
|
||||
<Route path="*">
|
||||
<Empty
|
||||
title="No tool selected"
|
||||
description="Please select a tool to continue."
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => openSidebar()}
|
||||
className="block md:hidden"
|
||||
>
|
||||
Open Tools
|
||||
</Button>
|
||||
</Empty>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RoutePathStateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,16 @@ import { combine } from "zustand/middleware";
|
||||
|
||||
import type { ToolJson } from "jsonv-ts/mcp";
|
||||
|
||||
const FEATURES = ["tools", "resources"] as const;
|
||||
export type Feature = (typeof FEATURES)[number];
|
||||
|
||||
export const useMcpStore = create(
|
||||
combine(
|
||||
{
|
||||
tools: [] as ToolJson[],
|
||||
feature: "tools" as Feature | null,
|
||||
content: null as ToolJson | null,
|
||||
history: [] as { type: "request" | "response"; data: any }[],
|
||||
historyLimit: 50,
|
||||
historyVisible: false,
|
||||
},
|
||||
(set) => ({
|
||||
setTools: (tools: ToolJson[]) => set({ tools }),
|
||||
setFeature: (feature: Feature) => set({ feature }),
|
||||
setContent: (content: ToolJson | null) => set({ content }),
|
||||
addHistory: (type: "request" | "response", data: any) =>
|
||||
set((state) => ({
|
||||
history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)],
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppShell } from "ui/layouts/AppShell";
|
||||
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer";
|
||||
import { twMerge } from "ui/elements/mocks/tailwind-merge";
|
||||
import { Field, Form } from "ui/components/form/json-schema-form";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
@@ -13,17 +12,18 @@ import { appShellStore } from "ui/store";
|
||||
import { Icon } from "ui/components/display/Icon";
|
||||
import { useMcpClient } from "./hooks/use-mcp-client";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { useParams } from "wouter";
|
||||
|
||||
export function Sidebar({ open, toggle }) {
|
||||
export function Sidebar() {
|
||||
const client = useMcpClient();
|
||||
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
|
||||
const tools = useMcpStore((state) => state.tools);
|
||||
const setTools = useMcpStore((state) => state.setTools);
|
||||
const setContent = useMcpStore((state) => state.setContent);
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(undefined!);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -39,15 +39,22 @@ export function Sidebar({ open, toggle }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleRefresh();
|
||||
handleRefresh().then(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const selectedTool = scrollContainerRef.current.querySelector(".active");
|
||||
if (selectedTool) {
|
||||
selectedTool.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
title="Tools"
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
renderHeaderRight={() => (
|
||||
identifier="tools"
|
||||
renderHeaderRight={({ active }) => (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{error && (
|
||||
<Tooltip label={error}>
|
||||
@@ -57,7 +64,7 @@ export function Sidebar({ open, toggle }) {
|
||||
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
|
||||
{tools.length}
|
||||
</span>
|
||||
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} />
|
||||
<IconButton Icon={TbRefresh} disabled={!active || loading} onClick={handleRefresh} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
@@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) {
|
||||
return (
|
||||
<AppShell.SidebarLink
|
||||
key={tool.name}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start h-auto py-3 gap-px",
|
||||
content?.name === tool.name ? "active" : "",
|
||||
)}
|
||||
className="flex flex-col items-start h-auto py-3 gap-px"
|
||||
as={Link}
|
||||
href={`/tools/${tool.name}`}
|
||||
onClick={() => {
|
||||
setContent(tool);
|
||||
//setContent(tool);
|
||||
closeSidebar();
|
||||
}}
|
||||
>
|
||||
@@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) {
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function Content() {
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const { toolName } = useParams();
|
||||
const tools = useMcpStore((state) => state.tools);
|
||||
const tool = tools.find((tool) => tool.name === toolName);
|
||||
const addHistory = useMcpStore((state) => state.addHistory);
|
||||
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
|
||||
const [payload, setPayload] = useState<object>(getTemplate(tool?.inputSchema));
|
||||
const [result, setResult] = useState<object | null>(null);
|
||||
const historyVisible = useMcpStore((state) => state.historyVisible);
|
||||
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
|
||||
const client = useMcpClient();
|
||||
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
|
||||
const hasInputSchema =
|
||||
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
|
||||
tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setPayload(getTemplate(content?.inputSchema));
|
||||
setPayload(getTemplate(tool?.inputSchema));
|
||||
setResult(null);
|
||||
}, [content]);
|
||||
}, [toolName]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!content?.name) return;
|
||||
if (!tool?.name) return;
|
||||
const request = {
|
||||
name: content.name,
|
||||
name: tool.name,
|
||||
arguments: payload,
|
||||
};
|
||||
startTransition(async () => {
|
||||
@@ -131,7 +139,7 @@ export function Content() {
|
||||
});
|
||||
}, [payload]);
|
||||
|
||||
if (!content) return null;
|
||||
if (!tool) return null;
|
||||
|
||||
let readableResult = result;
|
||||
try {
|
||||
@@ -144,11 +152,11 @@ export function Content() {
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="flex flex-grow flex-col min-w-0 max-w-screen"
|
||||
key={content.name}
|
||||
className="flex grow flex-col min-w-0 max-w-screen"
|
||||
key={tool.name}
|
||||
schema={{
|
||||
title: "InputSchema",
|
||||
...content?.inputSchema,
|
||||
...tool?.inputSchema,
|
||||
}}
|
||||
validateOn="submit"
|
||||
initialValues={payload}
|
||||
@@ -163,12 +171,12 @@ export function Content() {
|
||||
right={
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconButton
|
||||
Icon={historyVisible ? TbHistory : TbHistoryOff}
|
||||
Icon={historyVisible ? TbHistoryOff : TbHistory}
|
||||
onClick={() => setHistoryVisible(!historyVisible)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!content?.name || isPending}
|
||||
disabled={!tool?.name || isPending}
|
||||
variant="primary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
@@ -181,19 +189,19 @@ export function Content() {
|
||||
<span className="opacity-50">
|
||||
Tools <span className="opacity-70">/</span>
|
||||
</span>{" "}
|
||||
<span className="truncate">{content?.name}</span>
|
||||
<span className="truncate">{tool?.name}</span>
|
||||
</AppShell.SectionHeaderTitle>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-grow flex-row w-vw">
|
||||
<div className="flex grow flex-row w-vw">
|
||||
<div
|
||||
className="flex flex-grow flex-col max-w-full"
|
||||
className="flex grow flex-col max-w-full"
|
||||
style={{
|
||||
width: "calc(100% - var(--sidebar-width-right) - 1px)",
|
||||
}}
|
||||
>
|
||||
<AppShell.Scrollable>
|
||||
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
|
||||
<p className="text-primary/80">{content?.description}</p>
|
||||
<div key={JSON.stringify(tool)} className="flex flex-col py-4 px-5 gap-4">
|
||||
<p className="text-primary/80">{tool?.description}</p>
|
||||
|
||||
{hasInputSchema && <Field name="" />}
|
||||
<JsonViewerTabs
|
||||
@@ -209,7 +217,7 @@ export function Content() {
|
||||
},
|
||||
Result: { json: readableResult, title: "Result" },
|
||||
Configuration: {
|
||||
json: content ?? null,
|
||||
json: tool ?? null,
|
||||
title: "Configuration",
|
||||
},
|
||||
}}
|
||||
@@ -234,7 +242,7 @@ const History = () => {
|
||||
<>
|
||||
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-col flex-grow p-3 gap-1">
|
||||
<div className="flex grow flex-col p-3 gap-1">
|
||||
{history.map((item, i) => (
|
||||
<JsonViewer
|
||||
key={`${item.type}-${i}`}
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"@aws-sdk/client-s3": "^3.922.0",
|
||||
"@bluwy/giget-core": "^0.1.6",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@cloudflare/vite-plugin": "^1.15.3",
|
||||
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
||||
"@cloudflare/workers-types": "^4.20251014.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
@@ -114,7 +113,7 @@
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "3.0.9",
|
||||
"wouter": "^3.7.1",
|
||||
"wrangler": "^4.52.1",
|
||||
"wrangler": "^4.45.4",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hono/node-server": "^1.19.6",
|
||||
@@ -483,11 +482,9 @@
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@0.11.0", "https://registry.npmmirror.com/@clack/prompts/-/prompts-0.11.0.tgz", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.1", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg=="],
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.11", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251106.1" }, "optionalPeers": ["workerd"] }, "sha512-se23f1D4PxKrMKOq+Stz+Yn7AJ9ITHcEecXo2Yjb+UgbUDCEBch1FXQC6hx6uT5fNA3kmX3mfzeZiUmpK1W9IQ=="],
|
||||
|
||||
"@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.15.3", "", { "dependencies": { "@cloudflare/unenv-preset": "2.7.11", "@remix-run/node-fetch-server": "^0.8.0", "get-port": "^7.1.0", "miniflare": "4.20251125.0", "picocolors": "^1.1.1", "tinyglobby": "^0.2.12", "unenv": "2.0.0-rc.24", "wrangler": "4.51.0", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0" } }, "sha512-o199VPWhPoKolIW7bfdk1Vzfpa/gE+1wA+J/B8Is8MH/pAVhNQG1rVxAXjNsOAuRMwfJxRwBKDScwVVZFuUUlw=="],
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.9", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20250927.0" }, "optionalPeers": ["workerd"] }, "sha512-Drm7qlTKnvncEv+DANiQNEonq0H0LyIsoFZYJ6tJ8OhAoy5udIE8yp6BsVDYcIjcYLIybp4M7c/P7ly/56SoHg=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.10.4", "", { "dependencies": { "birpc": "0.2.14", "cjs-module-lexer": "^1.2.3", "devalue": "^5.3.2", "miniflare": "4.20251011.2", "semver": "^7.7.1", "wrangler": "4.45.4", "zod": "^3.22.3" }, "peerDependencies": { "@vitest/runner": "2.0.x - 3.2.x", "@vitest/snapshot": "2.0.x - 3.2.x", "vitest": "2.0.x - 3.2.x" } }, "sha512-PY9mexikRXEgJIXuFtr0ZWThbuBjq7VepC2sx8LpYizOLqoZ2NsUrJW+T+nojGxYNuzjulI8D14+mFezg7MhRQ=="],
|
||||
|
||||
@@ -591,8 +588,6 @@
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
|
||||
@@ -967,8 +962,6 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.8.1", "", {}, "sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig=="],
|
||||
|
||||
"@rjsf/core": ["@rjsf/core@5.22.2", "", { "dependencies": { "lodash": "^4.17.21", "lodash-es": "^4.17.21", "markdown-to-jsx": "^7.4.1", "nanoid": "^3.3.7", "prop-types": "^15.8.1" }, "peerDependencies": { "@rjsf/utils": "^5.22.x", "react": "^16.14.0 || >=17" } }, "sha512-awosG7y1fcu770GtKT64L3dHj3IF7iRiDwYU6jv1TUf5/Jf2TiWhTilTMyBfAEVfSiNWvh8U8dKH69k2WqswKQ=="],
|
||||
|
||||
"@rjsf/utils": ["@rjsf/utils@5.22.0", "", { "dependencies": { "json-schema-merge-allof": "^0.8.1", "jsonpointer": "^5.0.1", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "react-is": "^18.2.0" }, "peerDependencies": { "react": "^16.14.0 || >=17" } }, "sha512-lM9BxMziTo4R/WG82yBJA7qNrbFY/BxYE44rwacZoHW0wFprstXAu4qJpWcQihgWYSe0SRT7SxXJtsQG+TEjSw=="],
|
||||
@@ -3441,7 +3434,7 @@
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"tinypool": ["tinypool@1.0.2", "", {}, "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="],
|
||||
|
||||
@@ -3675,7 +3668,7 @@
|
||||
|
||||
"wouter": ["wouter@3.7.1", "", { "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw=="],
|
||||
|
||||
"wrangler": ["wrangler@4.52.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.12", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20251202.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251202.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251202.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-rIzDxzPnLAaqBF+SdHGd9Az0ELEWtIBwPp5diCR58p2F4C+KgNGGpPMFswMntuViQ2RKRgGbk4jIzStJoUUfjQ=="],
|
||||
"wrangler": ["wrangler@4.45.4", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.9", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20251011.2", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251011.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251011.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-niXT7B463wQi7WXIHjYK8txgWhuKQLrGmhjoR58SnPhlkq4wGjd3rFrkVyRc/O58clGTfs672BSGOph4XMoQKw=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@3.0.1", "", { "dependencies": { "string-width": "^2.1.1", "strip-ansi": "^4.0.0" } }, "sha512-iXR3tDXpbnTpzjKSylUJRkLuOrEC7hwEB221cgn6wtF8wpmz28puFXAEfPT5zrjM3wahygB//VuWEr1vTkDcNQ=="],
|
||||
|
||||
@@ -3857,16 +3850,10 @@
|
||||
|
||||
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare": ["miniflare@4.20251125.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251125.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-xY6deLx0Drt8GfGG2Fv0fHUocHAIG/Iv62Kl36TPfDzgq7/+DQ5gYNisxnmyISQdA/sm7kOvn2XRBncxjWYrLg=="],
|
||||
|
||||
"@cloudflare/vite-plugin/wrangler": ["wrangler@4.51.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.11", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20251125.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251125.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251125.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-JHv+58UxM2//e4kf9ASDwg016xd/OdDNDUKW6zLQyE7Uc9ayYKX1QJ9NsYtpo4dC1dfg6rT67pf1aNK1cTzUDg=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20251011.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251011.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-5oAaz6lqZus4QFwzEJiNtgpjZR2TBVwBeIhOW33V4gu+l23EukpKja831tFIX2o6sOD/hqZmKZHplOrWl3YGtQ=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.45.4", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.9", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20251011.2", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251011.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251011.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-niXT7B463wQi7WXIHjYK8txgWhuKQLrGmhjoR58SnPhlkq4wGjd3rFrkVyRc/O58clGTfs672BSGOph4XMoQKw=="],
|
||||
|
||||
"@cypress/request/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
@@ -4193,7 +4180,7 @@
|
||||
|
||||
"base/pascalcase": ["pascalcase@0.1.1", "", {}, "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw=="],
|
||||
|
||||
"bknd/miniflare": ["miniflare@4.20251125.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251125.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-xY6deLx0Drt8GfGG2Fv0fHUocHAIG/Iv62Kl36TPfDzgq7/+DQ5gYNisxnmyISQdA/sm7kOvn2XRBncxjWYrLg=="],
|
||||
"bknd/miniflare": ["miniflare@4.20251011.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251011.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-5oAaz6lqZus4QFwzEJiNtgpjZR2TBVwBeIhOW33V4gu+l23EukpKja831tFIX2o6sOD/hqZmKZHplOrWl3YGtQ=="],
|
||||
|
||||
"bknd-cli/@libsql/client": ["@libsql/client@0.14.0", "", { "dependencies": { "@libsql/core": "^0.14.0", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.4.4", "promise-limit": "^2.7.0" } }, "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q=="],
|
||||
|
||||
@@ -4497,8 +4484,6 @@
|
||||
|
||||
"postcss-mixins/sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
|
||||
|
||||
"postcss-mixins/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"pretty-format/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"progress-estimator/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||
@@ -4649,6 +4634,10 @@
|
||||
|
||||
"test-exclude/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
|
||||
"tinyglobby/fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
|
||||
"to-object-path/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"ts-jest/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
@@ -4667,8 +4656,6 @@
|
||||
|
||||
"tsup/rollup": ["rollup@4.35.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.35.0", "@rollup/rollup-android-arm64": "4.35.0", "@rollup/rollup-darwin-arm64": "4.35.0", "@rollup/rollup-darwin-x64": "4.35.0", "@rollup/rollup-freebsd-arm64": "4.35.0", "@rollup/rollup-freebsd-x64": "4.35.0", "@rollup/rollup-linux-arm-gnueabihf": "4.35.0", "@rollup/rollup-linux-arm-musleabihf": "4.35.0", "@rollup/rollup-linux-arm64-gnu": "4.35.0", "@rollup/rollup-linux-arm64-musl": "4.35.0", "@rollup/rollup-linux-loongarch64-gnu": "4.35.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0", "@rollup/rollup-linux-riscv64-gnu": "4.35.0", "@rollup/rollup-linux-s390x-gnu": "4.35.0", "@rollup/rollup-linux-x64-gnu": "4.35.0", "@rollup/rollup-linux-x64-musl": "4.35.0", "@rollup/rollup-win32-arm64-msvc": "4.35.0", "@rollup/rollup-win32-ia32-msvc": "4.35.0", "@rollup/rollup-win32-x64-msvc": "4.35.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg=="],
|
||||
|
||||
"tsup/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"union-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"unset-value/has-value": ["has-value@0.3.1", "", { "dependencies": { "get-value": "^2.0.3", "has-values": "^0.1.4", "isobject": "^2.0.0" } }, "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q=="],
|
||||
@@ -4689,6 +4676,8 @@
|
||||
|
||||
"vite/rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
|
||||
|
||||
"vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"vite-node/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
||||
|
||||
"vite-node/vite": ["vite@6.2.1", "", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q=="],
|
||||
@@ -4717,13 +4706,9 @@
|
||||
|
||||
"wouter/use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="],
|
||||
|
||||
"wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.12", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251125.0" }, "optionalPeers": ["workerd"] }, "sha512-SIBo+k58R9OyBsxF1jL6GdL7XHbzatT86c0be+UY5v5tg6TAuJ1/2QsRuC3pHgKVHile1HcJqEEORoS9hv8hNw=="],
|
||||
"wrangler/miniflare": ["miniflare@4.20251011.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251011.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-5oAaz6lqZus4QFwzEJiNtgpjZR2TBVwBeIhOW33V4gu+l23EukpKja831tFIX2o6sOD/hqZmKZHplOrWl3YGtQ=="],
|
||||
|
||||
"wrangler/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
|
||||
"wrangler/miniflare": ["miniflare@4.20251202.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251202.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Pa5iBAVzzVT/yr7rcyr75ETm5IGCpdT61foGx+6jDj+vzISNfWZgEcSxWk1nlJboJumUJ10kC498hQudpdbDWg=="],
|
||||
|
||||
"wrangler/workerd": ["workerd@1.20251202.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251202.0", "@cloudflare/workerd-darwin-arm64": "1.20251202.0", "@cloudflare/workerd-linux-64": "1.20251202.0", "@cloudflare/workerd-linux-arm64": "1.20251202.0", "@cloudflare/workerd-windows-64": "1.20251202.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-p08YfrUMHkjCECNdT36r+6DpJIZX4kixbZ4n6GMUcLR5Gh18fakSCsiQrh72iOm4M9QHv/rM7P8YvCrUPWT5sg=="],
|
||||
"wrangler/workerd": ["workerd@1.20251011.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251011.0", "@cloudflare/workerd-darwin-arm64": "1.20251011.0", "@cloudflare/workerd-linux-64": "1.20251011.0", "@cloudflare/workerd-linux-arm64": "1.20251011.0", "@cloudflare/workerd-windows-64": "1.20251011.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q=="],
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="],
|
||||
|
||||
@@ -4769,26 +4754,12 @@
|
||||
|
||||
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/workerd": ["workerd@1.20251125.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251125.0", "@cloudflare/workerd-darwin-arm64": "1.20251125.0", "@cloudflare/workerd-linux-64": "1.20251125.0", "@cloudflare/workerd-linux-arm64": "1.20251125.0", "@cloudflare/workerd-windows-64": "1.20251125.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oQYfgu3UZ15HlMcEyilKD1RdielRnKSG5MA0xoi1theVs99Rop9AEFYicYCyK1R4YjYblLRYEiL1tMgEFqpReA=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"@cloudflare/vite-plugin/wrangler/workerd": ["workerd@1.20251125.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251125.0", "@cloudflare/workerd-darwin-arm64": "1.20251125.0", "@cloudflare/workerd-linux-64": "1.20251125.0", "@cloudflare/workerd-linux-arm64": "1.20251125.0", "@cloudflare/workerd-windows-64": "1.20251125.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oQYfgu3UZ15HlMcEyilKD1RdielRnKSG5MA0xoi1theVs99Rop9AEFYicYCyK1R4YjYblLRYEiL1tMgEFqpReA=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20251011.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251011.0", "@cloudflare/workerd-darwin-arm64": "1.20251011.0", "@cloudflare/workerd-linux-64": "1.20251011.0", "@cloudflare/workerd-linux-arm64": "1.20251011.0", "@cloudflare/workerd-windows-64": "1.20251011.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.9", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20250927.0" }, "optionalPeers": ["workerd"] }, "sha512-Drm7qlTKnvncEv+DANiQNEonq0H0LyIsoFZYJ6tJ8OhAoy5udIE8yp6BsVDYcIjcYLIybp4M7c/P7ly/56SoHg=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20251011.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251011.0", "@cloudflare/workerd-darwin-arm64": "1.20251011.0", "@cloudflare/workerd-linux-64": "1.20251011.0", "@cloudflare/workerd-linux-arm64": "1.20251011.0", "@cloudflare/workerd-windows-64": "1.20251011.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
@@ -4903,8 +4874,6 @@
|
||||
|
||||
"@vitest/mocker/vite/rollup": ["rollup@4.35.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.35.0", "@rollup/rollup-android-arm64": "4.35.0", "@rollup/rollup-darwin-arm64": "4.35.0", "@rollup/rollup-darwin-x64": "4.35.0", "@rollup/rollup-freebsd-arm64": "4.35.0", "@rollup/rollup-freebsd-x64": "4.35.0", "@rollup/rollup-linux-arm-gnueabihf": "4.35.0", "@rollup/rollup-linux-arm-musleabihf": "4.35.0", "@rollup/rollup-linux-arm64-gnu": "4.35.0", "@rollup/rollup-linux-arm64-musl": "4.35.0", "@rollup/rollup-linux-loongarch64-gnu": "4.35.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0", "@rollup/rollup-linux-riscv64-gnu": "4.35.0", "@rollup/rollup-linux-s390x-gnu": "4.35.0", "@rollup/rollup-linux-x64-gnu": "4.35.0", "@rollup/rollup-linux-x64-musl": "4.35.0", "@rollup/rollup-win32-arm64-msvc": "4.35.0", "@rollup/rollup-win32-ia32-msvc": "4.35.0", "@rollup/rollup-win32-x64-msvc": "4.35.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg=="],
|
||||
|
||||
"@vitest/mocker/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"@vitest/snapshot/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@vitest/ui/tinyglobby/fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="],
|
||||
@@ -4943,7 +4912,7 @@
|
||||
|
||||
"bknd/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
|
||||
"bknd/miniflare/workerd": ["workerd@1.20251125.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251125.0", "@cloudflare/workerd-darwin-arm64": "1.20251125.0", "@cloudflare/workerd-linux-64": "1.20251125.0", "@cloudflare/workerd-linux-arm64": "1.20251125.0", "@cloudflare/workerd-windows-64": "1.20251125.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oQYfgu3UZ15HlMcEyilKD1RdielRnKSG5MA0xoi1theVs99Rop9AEFYicYCyK1R4YjYblLRYEiL1tMgEFqpReA=="],
|
||||
"bknd/miniflare/workerd": ["workerd@1.20251011.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251011.0", "@cloudflare/workerd-darwin-arm64": "1.20251011.0", "@cloudflare/workerd-linux-64": "1.20251011.0", "@cloudflare/workerd-linux-arm64": "1.20251011.0", "@cloudflare/workerd-windows-64": "1.20251011.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q=="],
|
||||
|
||||
"bknd/miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
@@ -5117,10 +5086,6 @@
|
||||
|
||||
"object-copy/define-property/is-descriptor": ["is-descriptor@0.1.7", "", { "dependencies": { "is-accessor-descriptor": "^1.0.1", "is-data-descriptor": "^1.0.1" } }, "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg=="],
|
||||
|
||||
"postcss-mixins/tinyglobby/fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
|
||||
|
||||
"postcss-mixins/tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
|
||||
"progress-estimator/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||
|
||||
"progress-estimator/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
||||
@@ -5181,10 +5146,6 @@
|
||||
|
||||
"tsup/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"tsup/tinyglobby/fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="],
|
||||
|
||||
"tsup/tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
|
||||
"unset-value/has-value/has-values": ["has-values@0.1.4", "", {}, "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ=="],
|
||||
|
||||
"unset-value/has-value/isobject": ["isobject@2.1.0", "", { "dependencies": { "isarray": "1.0.0" } }, "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA=="],
|
||||
@@ -5247,69 +5208,19 @@
|
||||
|
||||
"webdriverio/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
|
||||
|
||||
"wrangler/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
|
||||
"wrangler/miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251202.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/uvEAWEukTWb1geHhbjGUeZqcSSSyYzp0mvoPUBl+l0ont4NVGao3fgwM0q8wtKvgoKCHSG6zcG23wj9Opj3Nw=="],
|
||||
"wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251011.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg=="],
|
||||
|
||||
"wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251202.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f52xRvcI9cWRd6400EZStRtXiRC5XKEud7K5aFIbbUv0VeINltujFQQ9nHWtsF6g1quIXWkjhh5u01gPAYNNXA=="],
|
||||
"wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251011.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA=="],
|
||||
|
||||
"wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251202.0", "", { "os": "linux", "cpu": "x64" }, "sha512-HYXinF5RBH7oXbsFUMmwKCj+WltpYbf5mRKUBG5v3EuPhUjSIFB84U+58pDyfBJjcynHdy3EtvTWcvh/+lcgow=="],
|
||||
"wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251011.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BccMiBzFlWZyFghIw2szanmYJrJGBGHomw2y/GV6pYXChFzMGZkeCEMfmCyJj29xczZXxcZmUVJxNy4eJxO8QA=="],
|
||||
|
||||
"wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251202.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-++L02Jdoxz7hEA9qDaQjbVU1RzQS+S+eqIi22DkPe2Tgiq2M3UfNpeu+75k5L9DGRIkZPYvwMBMbcmKvQqdIIg=="],
|
||||
"wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251011.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-79o/216lsbAbKEVDZYXR24ivEIE2ysDL9jvo0rDTkViLWju9dAp3CpyetglpJatbSi3uWBPKZBEOqN68zIjVsQ=="],
|
||||
|
||||
"wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251202.0", "", { "os": "win32", "cpu": "x64" }, "sha512-gzeU6eDydTi7ib+Q9DD/c0hpXtqPucnHk2tfGU03mljPObYxzMkkPGgB5qxpksFvub3y4K0ChjqYxGJB4F+j3g=="],
|
||||
"wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251011.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RIXUQRchFdqEvaUqn1cXZXSKjpqMaSaVAkI5jNZ8XzAw/bw2bcdOVUtakrflgxDprltjFb0PTNtuss1FKtH9Jg=="],
|
||||
|
||||
"wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
|
||||
|
||||
@@ -5327,28 +5238,6 @@
|
||||
|
||||
"@bknd/plasmic/@types/bun/bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251125.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDIVJi8fPxBseRoEIzLiUJb0N+DXnah/ynS+Unzn58HEoKLetUWiV/T1Fhned//lo5krnToG9KRgVRs0SOOTpw=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251125.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k5FQET5PXnWjeDqZUpl4Ah/Rn0bH6mjfUtTyeAy6ky7QB3AZpwIhgWQD0vOFB3OvJaK4J/K4cUtNChYXB9mY/A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251125.0", "", { "os": "linux", "cpu": "x64" }, "sha512-at6n/FomkftykWx0EqVLUZ0juUFz3ORtEPeBbW9ZZ3BQEyfVUtYfdcz/f1cN8Yyb7TE9ovF071P0mBRkx83ODw=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251125.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiRn+jrNaIs1QveabXGHFoyn3s/l02ui6Yp3nssyNhtmtgviddtt8KObBfM1jQKjXTpZlunhwdN4Bxf4jhlOMw=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251125.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6fdIsSeu65g++k8Y2DKzNKs0BkoU+KKI6GAAVBOLh2vvVWWnCP1OgMdVb5JAdjDrjDT5i0GSQu0bgQ8fPsW6zw=="],
|
||||
|
||||
"@cloudflare/vite-plugin/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251125.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDIVJi8fPxBseRoEIzLiUJb0N+DXnah/ynS+Unzn58HEoKLetUWiV/T1Fhned//lo5krnToG9KRgVRs0SOOTpw=="],
|
||||
|
||||
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251125.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k5FQET5PXnWjeDqZUpl4Ah/Rn0bH6mjfUtTyeAy6ky7QB3AZpwIhgWQD0vOFB3OvJaK4J/K4cUtNChYXB9mY/A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251125.0", "", { "os": "linux", "cpu": "x64" }, "sha512-at6n/FomkftykWx0EqVLUZ0juUFz3ORtEPeBbW9ZZ3BQEyfVUtYfdcz/f1cN8Yyb7TE9ovF071P0mBRkx83ODw=="],
|
||||
|
||||
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251125.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiRn+jrNaIs1QveabXGHFoyn3s/l02ui6Yp3nssyNhtmtgviddtt8KObBfM1jQKjXTpZlunhwdN4Bxf4jhlOMw=="],
|
||||
|
||||
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251125.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6fdIsSeu65g++k8Y2DKzNKs0BkoU+KKI6GAAVBOLh2vvVWWnCP1OgMdVb5JAdjDrjDT5i0GSQu0bgQ8fPsW6zw=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251011.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251011.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA=="],
|
||||
@@ -5361,16 +5250,6 @@
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251011.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251011.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251011.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BccMiBzFlWZyFghIw2szanmYJrJGBGHomw2y/GV6pYXChFzMGZkeCEMfmCyJj29xczZXxcZmUVJxNy4eJxO8QA=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251011.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-79o/216lsbAbKEVDZYXR24ivEIE2ysDL9jvo0rDTkViLWju9dAp3CpyetglpJatbSi3uWBPKZBEOqN68zIjVsQ=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251011.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RIXUQRchFdqEvaUqn1cXZXSKjpqMaSaVAkI5jNZ8XzAw/bw2bcdOVUtakrflgxDprltjFb0PTNtuss1FKtH9Jg=="],
|
||||
|
||||
"@plasmicapp/nextjs-app-router/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"@verdaccio/logger-prettify/pino-abstract-transport/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
@@ -5423,15 +5302,15 @@
|
||||
|
||||
"bknd-cli/@libsql/client/libsql/@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw=="],
|
||||
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251125.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDIVJi8fPxBseRoEIzLiUJb0N+DXnah/ynS+Unzn58HEoKLetUWiV/T1Fhned//lo5krnToG9KRgVRs0SOOTpw=="],
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251011.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg=="],
|
||||
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251125.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k5FQET5PXnWjeDqZUpl4Ah/Rn0bH6mjfUtTyeAy6ky7QB3AZpwIhgWQD0vOFB3OvJaK4J/K4cUtNChYXB9mY/A=="],
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251011.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA=="],
|
||||
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251125.0", "", { "os": "linux", "cpu": "x64" }, "sha512-at6n/FomkftykWx0EqVLUZ0juUFz3ORtEPeBbW9ZZ3BQEyfVUtYfdcz/f1cN8Yyb7TE9ovF071P0mBRkx83ODw=="],
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251011.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BccMiBzFlWZyFghIw2szanmYJrJGBGHomw2y/GV6pYXChFzMGZkeCEMfmCyJj29xczZXxcZmUVJxNy4eJxO8QA=="],
|
||||
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251125.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiRn+jrNaIs1QveabXGHFoyn3s/l02ui6Yp3nssyNhtmtgviddtt8KObBfM1jQKjXTpZlunhwdN4Bxf4jhlOMw=="],
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251011.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-79o/216lsbAbKEVDZYXR24ivEIE2ysDL9jvo0rDTkViLWju9dAp3CpyetglpJatbSi3uWBPKZBEOqN68zIjVsQ=="],
|
||||
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251125.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6fdIsSeu65g++k8Y2DKzNKs0BkoU+KKI6GAAVBOLh2vvVWWnCP1OgMdVb5JAdjDrjDT5i0GSQu0bgQ8fPsW6zw=="],
|
||||
"bknd/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251011.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RIXUQRchFdqEvaUqn1cXZXSKjpqMaSaVAkI5jNZ8XzAw/bw2bcdOVUtakrflgxDprltjFb0PTNtuss1FKtH9Jg=="],
|
||||
|
||||
"bknd/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["nextjs", "react-router", "astro", "vite"]
|
||||
"pages": ["nextjs", "react-router", "astro", "sveltekit", "vite"]
|
||||
}
|
||||
|
||||
@@ -73,7 +73,9 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> =
|
||||
|
||||
## Serve the API
|
||||
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
||||
### Helper Functions (Optional)
|
||||
|
||||
For convenience, you can create a helper file to instantiate the bknd instance and retrieve the API. This is optional but recommended as it simplifies usage throughout your app. The examples below assume you've created this helper, but you can adjust the approach according to your needs.
|
||||
|
||||
```ts title="app/bknd.ts"
|
||||
import {
|
||||
@@ -108,7 +110,9 @@ export async function getApi(
|
||||
|
||||
For more information about the connection object, refer to the [Database](/usage/database) guide.
|
||||
|
||||
Create a new api splat route file at `app/routes/api.$.ts`:
|
||||
### API Route
|
||||
|
||||
Create a catch-all route file at `app/routes/api.$.ts` that forwards requests to bknd:
|
||||
|
||||
```ts title="app/routes/api.$.ts"
|
||||
import { getApp } from "~/bknd";
|
||||
@@ -122,9 +126,22 @@ export const loader = handler;
|
||||
export const action = handler;
|
||||
```
|
||||
|
||||
If you're using [`@react-router/fs-routes`](https://reactrouter.com/how-to/file-route-conventions), this file will automatically be picked up as a route.
|
||||
|
||||
If you're manually defining routes in [`app/routes.ts`](https://reactrouter.com/api/framework-conventions/routes.ts), reference this file in your configuration:
|
||||
|
||||
```ts title="app/routes.ts"
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
// your other routes...
|
||||
route("api/*", "./routes/api.$.ts"),
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
## Enabling the Admin UI
|
||||
|
||||
Create a new splat route file at `app/routes/admin.$.tsx`:
|
||||
Create a route file at `app/routes/admin.$.tsx` to enable the bknd Admin UI for managing your data, schema, and users:
|
||||
|
||||
```tsx title="app/routes/admin.$.tsx"
|
||||
import { lazy, Suspense, useSyncExternalStore } from "react";
|
||||
@@ -165,6 +182,19 @@ export default function AdminPage() {
|
||||
}
|
||||
```
|
||||
|
||||
If you're using [`@react-router/fs-routes`](https://reactrouter.com/how-to/file-route-conventions), this file will automatically be picked up as a route.
|
||||
|
||||
If you're manually defining routes in `app/routes.ts`, reference this file in your configuration:
|
||||
|
||||
```ts title="app/routes.ts"
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
// your other routes...
|
||||
route("admin/*", "./routes/admin.$.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
## Example usage of the API
|
||||
|
||||
You can use the `getApi` helper function we've already set up to fetch and mutate:
|
||||
@@ -193,3 +223,24 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using React Hooks (Optional)
|
||||
|
||||
If you want to use bknd's client-side React hooks (like `useEntityQuery`, `useAuth`, etc.), wrap your app in the `ClientProvider` component. This is typically done in `app/root.tsx`:
|
||||
|
||||
```tsx title="app/root.tsx"
|
||||
// other imports
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
// ...
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider>
|
||||
<Outlet />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
The `ClientProvider` automatically uses the same origin for API requests, which works perfectly when bknd is served from your React Router app. For more details on using React hooks, see the [React SDK documentation](/usage/react).
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
title: "SvelteKit"
|
||||
description: "Run bknd inside SvelteKit"
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
To get started with SvelteKit and bknd, create a new SvelteKit project by following the [official guide](https://svelte.dev/docs/kit/creating-a-project), and then install bknd as a dependency:
|
||||
|
||||
<Tabs groupId='package-manager' persist items={[ 'npm', 'pnpm', 'yarn', 'bun']}>
|
||||
|
||||
```bash tab="npm"
|
||||
npm install bknd
|
||||
```
|
||||
|
||||
```bash tab="pnpm"
|
||||
pnpm install bknd
|
||||
```
|
||||
|
||||
```bash tab="yarn"
|
||||
yarn add bknd
|
||||
```
|
||||
|
||||
```bash tab="bun"
|
||||
bun add bknd
|
||||
```
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
<Callout type="warning">
|
||||
When run with Node.js, a version of 22 (LTS) or higher is required. Please
|
||||
verify your version by running `node -v`, and
|
||||
[upgrade](https://nodejs.org/en/download/) if necessary.
|
||||
</Callout>
|
||||
|
||||
Now create a `bknd.config.ts` file in the root of your project:
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import type { SvelteKitBkndConfig } from "bknd/adapter/sveltekit";
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db",
|
||||
},
|
||||
} satisfies SvelteKitBkndConfig;
|
||||
```
|
||||
|
||||
See [bknd.config.ts](/extending/config) for more information on how to configure bknd. The `SvelteKitBkndConfig` type extends the base config type with the following properties:
|
||||
|
||||
```typescript
|
||||
export type SvelteKitBkndConfig<Env> = Pick<RuntimeBkndConfig<Env>, "adminOptions">;
|
||||
```
|
||||
|
||||
## Serve the API
|
||||
|
||||
The SvelteKit adapter uses SvelteKit's hooks mechanism to handle API requests. Create a `src/hooks.server.ts` file:
|
||||
|
||||
```typescript title="src/hooks.server.ts"
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
import { serve } from "bknd/adapter/sveltekit";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import config from "../bknd.config";
|
||||
|
||||
const bkndHandler = serve(config, env);
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// handle bknd API requests
|
||||
const pathname = event.url.pathname;
|
||||
if (pathname.startsWith("/api/")) {
|
||||
const res = await bkndHandler(event);
|
||||
if (res.status !== 404) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
```
|
||||
|
||||
For more information about the connection object, refer to the [Database](/usage/database) guide.
|
||||
|
||||
<Callout type="info">
|
||||
The adapter uses `$env/dynamic/private` to access environment variables, making it runtime-agnostic
|
||||
and compatible with any deployment target (Node.js, Bun, Cloudflare, etc.).
|
||||
</Callout>
|
||||
|
||||
## Enabling the Admin UI
|
||||
|
||||
The SvelteKit adapter supports serving the Admin UI statically. First, copy the required assets to your `static` folder by adding a postinstall script to your `package.json`:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"scripts": {
|
||||
"postinstall": "bknd copy-assets --out static" // [!code highlight]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then update your `bknd.config.ts` to configure the admin base path:
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import type { SvelteKitBkndConfig } from "bknd/adapter/sveltekit";
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db",
|
||||
},
|
||||
adminOptions: { // [!code highlight]
|
||||
adminBasepath: "/admin" // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
} satisfies SvelteKitBkndConfig;
|
||||
```
|
||||
|
||||
Finally, update your `hooks.server.ts` to also handle admin routes:
|
||||
|
||||
```typescript title="src/hooks.server.ts"
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
import { serve } from "bknd/adapter/sveltekit";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import config from "../bknd.config";
|
||||
|
||||
const bkndHandler = serve(config, env);
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// handle bknd API and admin requests
|
||||
const pathname = event.url.pathname;
|
||||
if (pathname.startsWith("/api/") || pathname.startsWith("/admin")) { // [!code highlight]
|
||||
const res = await bkndHandler(event);
|
||||
if (res.status !== 404) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
```
|
||||
|
||||
## Example usage of the API
|
||||
|
||||
You can use the `getApp` function to access the bknd API in your server-side load functions:
|
||||
|
||||
```typescript title="src/routes/+page.server.ts"
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getApp } from "bknd/adapter/sveltekit";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import config from "../../bknd.config";
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const app = await getApp(config, env);
|
||||
const api = app.getApi();
|
||||
|
||||
const todos = await api.data.readMany("todos");
|
||||
|
||||
return {
|
||||
todos: todos.data ?? [],
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Then display the data in your Svelte component:
|
||||
|
||||
```svelte title="src/routes/+page.svelte"
|
||||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<h1>Todos</h1>
|
||||
<ul>
|
||||
{#each data.todos as todo (todo.id)}
|
||||
<li>{todo.title}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Using authentication
|
||||
|
||||
To use authentication in your load functions, pass the request headers to the API:
|
||||
|
||||
```typescript title="src/routes/+page.server.ts"
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getApp } from "bknd/adapter/sveltekit";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import config from "../../bknd.config";
|
||||
|
||||
export const load: PageServerLoad = async ({ request }) => {
|
||||
const app = await getApp(config, env);
|
||||
const api = app.getApi({ headers: request.headers });
|
||||
await api.verifyAuth();
|
||||
|
||||
const todos = await api.data.readMany("todos");
|
||||
|
||||
return {
|
||||
todos: todos.data ?? [],
|
||||
user: api.getUser(),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Check the [SvelteKit repository example](https://github.com/bknd-io/bknd/tree/main/examples/sveltekit) for more implementation details.
|
||||
@@ -27,6 +27,12 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you
|
||||
href="/integration/astro"
|
||||
/>
|
||||
|
||||
<Card
|
||||
icon={<Icon icon="simple-icons:svelte" className="text-fd-primary !size-6" />}
|
||||
title="SvelteKit"
|
||||
href="/integration/sveltekit"
|
||||
/>
|
||||
|
||||
<Card title="Yours missing?" href="https://github.com/bknd-io/bknd/issues/new">
|
||||
Create a new issue to request a guide for your framework.
|
||||
</Card>
|
||||
|
||||
@@ -103,6 +103,12 @@ Start by using the integration guide for these popular frameworks/runtimes. Ther
|
||||
href="/integration/deno"
|
||||
/>
|
||||
|
||||
<Card
|
||||
icon={<Icon icon="simple-icons:svelte" className="text-fd-primary !size-6" />}
|
||||
title="SvelteKit"
|
||||
href="/integration/sveltekit"
|
||||
/>
|
||||
|
||||
<Card
|
||||
icon={<Icon icon="tabler:lambda" className="text-fd-primary !size-6" />}
|
||||
title="AWS Lambda"
|
||||
|
||||
@@ -4,19 +4,24 @@ description: "Use the bknd SDK for React"
|
||||
icon: React
|
||||
tags: ["documentation"]
|
||||
---
|
||||
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
||||
|
||||
There are 4 useful hooks to work with your backend:
|
||||
There are several useful hooks to work with your backend:
|
||||
|
||||
1. simple hooks which are solely based on the [API](/usage/sdk):
|
||||
- [`useApi`](#useapi)
|
||||
- [`useEntity`](#useentity)
|
||||
2. query hooks that wraps the API in [SWR](https://swr.vercel.app/):
|
||||
- [`useApiQuery`](#useapiquery)
|
||||
- [`useEntityQuery`](#useentityquery)
|
||||
1. **Simple hooks** based on the [API](/usage/sdk):
|
||||
- [`useApi`](#useapi) - Access the API instance
|
||||
- [`useAuth`](#useauth) - Authentication helpers and state
|
||||
- [`useEntity`](#useentity) - CRUD operations without caching
|
||||
2. **Query hooks** that wrap the API in [SWR](https://swr.vercel.app/):
|
||||
- [`useApiQuery`](#useapiquery) - Query any API endpoint with caching
|
||||
- [`useEntityQuery`](#useentityquery) - Entity CRUD with automatic caching
|
||||
3. **Utility hooks** for advanced use cases:
|
||||
- [`useInvalidate`](#useinvalidate) - Manual cache invalidation
|
||||
- [`useEntityMutate`](#useentitymutate) - Mutations without fetching
|
||||
|
||||
## Setup
|
||||
|
||||
In order to use them, make sure you wrap your `<App />` inside `<ClientProvider />`, so that these hooks point to your bknd instance:
|
||||
In order to use the React hooks, make sure you wrap your `<App />` inside `<ClientProvider />`. This provides the bknd API instance to all hooks in your component tree:
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
@@ -26,21 +31,434 @@ export default function App() {
|
||||
}
|
||||
```
|
||||
|
||||
For all other examples below, we'll assume that your app is wrapped inside the `ClientProvider`.
|
||||
### ClientProvider Props
|
||||
|
||||
The `ClientProvider` accepts the following props:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
baseUrl: {
|
||||
description: 'The base URL of your bknd instance (similar to host in the API). If left blank, it points to the same origin, which is useful when bknd is served from your framework (e.g., Next.js, Astro, React Router)',
|
||||
type: 'string',
|
||||
},
|
||||
children: {
|
||||
description: 'React components that will have access to the bknd context',
|
||||
type: 'ReactNode',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
All [Api options](/usage/sdk#setup) are also supported and will be passed to the internal API instance. Common options include:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
token: {
|
||||
description: 'Authentication token for API requests',
|
||||
type: 'string',
|
||||
},
|
||||
storage: {
|
||||
description: 'Custom storage implementation for persisting tokens (defaults to localStorage in browsers)',
|
||||
type: '{ getItem, setItem, removeItem }',
|
||||
},
|
||||
onAuthStateChange: {
|
||||
description: 'Callback function triggered when authentication state changes',
|
||||
type: '(state: AuthState) => void',
|
||||
},
|
||||
fetcher: {
|
||||
description: 'Custom fetch implementation (useful for local/embedded mode)',
|
||||
type: '(input: RequestInfo, init?: RequestInit) => Promise<Response>',
|
||||
},
|
||||
credentials: {
|
||||
description: 'Request credentials mode',
|
||||
type: '"include" | "omit" | "same-origin"',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Using with a remote bknd instance:**
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider baseUrl="https://your-bknd-instance.com">
|
||||
{/* your app */}
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Using with an embedded bknd instance (same origin):**
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
// no baseUrl needed - will use window.location.origin
|
||||
return <ClientProvider>{/* your app */}</ClientProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
**Using with custom authentication:**
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider
|
||||
baseUrl="https://your-bknd-instance.com"
|
||||
token="your-auth-token"
|
||||
onAuthStateChange={(state) => {
|
||||
console.log("Auth state changed:", state);
|
||||
}}
|
||||
>
|
||||
{/* your app */}
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For all examples below, we'll assume that your app is wrapped inside the `ClientProvider`.
|
||||
|
||||
## `useApi()`
|
||||
|
||||
To use the simple hook that returns the Api, you can use:
|
||||
Returns the [Api instance](/usage/sdk) from the `ClientProvider` context. This gives you direct access to all API methods for data, auth, media, and system operations.
|
||||
|
||||
```tsx
|
||||
import { useApi } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
export default async function App() {
|
||||
const api = useApi();
|
||||
|
||||
// access data API
|
||||
const posts = await api.data.readMany("posts");
|
||||
|
||||
// access auth API
|
||||
const user = await api.auth.me();
|
||||
|
||||
// access media API
|
||||
const files = await api.media.listFiles();
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
host: {
|
||||
description: 'Optional host URL to create a new Api instance instead of using the one from context',
|
||||
type: 'string',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
See the [SDK documentation](/usage/sdk) for all available API methods and options.
|
||||
|
||||
## `useAuth()`
|
||||
|
||||
Provides authentication state and helper functions for login, register, logout, and token management. This hook automatically tracks the authentication state from the `ClientProvider` context.
|
||||
|
||||
```tsx
|
||||
import { useAuth } from "bknd/client";
|
||||
|
||||
export default function AuthComponent() {
|
||||
const { user, verified, login, logout } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await login({
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Welcome, {user.email}!</p>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
baseUrl: {
|
||||
description: 'Optional base URL to use a different bknd instance',
|
||||
type: 'string',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Return Values
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
data: {
|
||||
description: 'The complete authentication state object',
|
||||
type: 'Partial<AuthState>',
|
||||
},
|
||||
user: {
|
||||
description: 'The currently authenticated user (or undefined if not authenticated)',
|
||||
type: 'SafeUser | undefined',
|
||||
},
|
||||
token: {
|
||||
description: 'The current authentication token',
|
||||
type: 'string | undefined',
|
||||
},
|
||||
verified: {
|
||||
description: 'Whether the token has been verified with the server',
|
||||
type: 'boolean',
|
||||
},
|
||||
login: {
|
||||
description: 'Login with email and password',
|
||||
type: '(data: { email: string; password: string }) => Promise<AuthResponse>',
|
||||
},
|
||||
register: {
|
||||
description: 'Register a new user with email and password',
|
||||
type: '(data: { email: string; password: string }) => Promise<AuthResponse>',
|
||||
},
|
||||
logout: {
|
||||
description: 'Logout and invalidate all cached data',
|
||||
type: '() => Promise<void>',
|
||||
},
|
||||
verify: {
|
||||
description: 'Verify the current token with the server and invalidate cache',
|
||||
type: '() => Promise<void>',
|
||||
},
|
||||
setToken: {
|
||||
description: 'Manually set the authentication token',
|
||||
type: '(token: string) => void',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Usage Notes
|
||||
|
||||
- The `login` and `register` functions automatically update the authentication state and store the token
|
||||
- The `logout` function clears the token and invalidates all SWR cache entries
|
||||
- The `verify` function checks if the current token is still valid with the server
|
||||
- Authentication state changes are automatically propagated to all components using `useAuth`
|
||||
|
||||
### Authentication Patterns
|
||||
|
||||
Depending on your deployment architecture, there are different ways to handle authentication:
|
||||
|
||||
#### 1. SPA with localStorage (Independent Deployments)
|
||||
|
||||
Use this pattern when your frontend and backend are deployed independently on different domains. The token is stored in the browser's localStorage.
|
||||
|
||||
```tsx
|
||||
import { ClientProvider, useAuth } from "bknd/client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// setup ClientProvider with localStorage
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider
|
||||
baseUrl="https://your-backend.com"
|
||||
storage={window.localStorage}
|
||||
>
|
||||
<AuthComponent />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthComponent() {
|
||||
const auth = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
// important: verify auth on mount to check if stored token is still valid
|
||||
useEffect(() => {
|
||||
auth.verify();
|
||||
}, []);
|
||||
|
||||
if (auth.user) {
|
||||
return (
|
||||
<div>
|
||||
<p>Logged in as {auth.user.email}</p>
|
||||
<button onClick={() => auth.logout()}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await auth.login({ email, password });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. SPA with Cookies (Same Domain)
|
||||
|
||||
Use this pattern when your frontend and backend are deployed on the same domain or when using a framework that serves both. Authentication is handled via HTTP-only cookies.
|
||||
|
||||
```tsx
|
||||
import { ClientProvider, useAuth } from "bknd/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// setup ClientProvider with credentials included
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider
|
||||
baseUrl="https://your-app.com"
|
||||
credentials="include"
|
||||
>
|
||||
<InnerApp />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerApp() {
|
||||
const auth = useAuth();
|
||||
|
||||
// important: verify auth on mount since cookies aren't readable from client-side JavaScript
|
||||
// cookies are included automatically in requests
|
||||
useEffect(() => {
|
||||
auth.verify();
|
||||
}, []);
|
||||
|
||||
if (auth.user) {
|
||||
return (
|
||||
<div>
|
||||
<p>Logged in as {auth.user.email}</p>
|
||||
{/* logout by navigating to the logout endpoint */}
|
||||
<a href="/api/auth/logout">Logout</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// option 1: programmatic login
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await auth.login({ email: "user@example.com", password: "password" });
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
);
|
||||
|
||||
// option 2: form-based login (traditional)
|
||||
return (
|
||||
<form method="post" action="/api/auth/password/login">
|
||||
<input type="email" name="email" placeholder="Email" />
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- With `credentials: "include"`, cookies are automatically sent with every request
|
||||
- The logout endpoint (`/api/auth/logout`) clears the cookie and redirects back to the referrer
|
||||
- You can use either programmatic login with `auth.login()` or traditional form submission
|
||||
|
||||
#### 3. Full Stack (Embedded Mode)
|
||||
|
||||
Use this pattern when bknd is embedded in your framework (e.g., Next.js, Astro, React Router). The backend and frontend run in the same process.
|
||||
|
||||
```tsx
|
||||
// this example is not specific to any framework, but you can use it with any framework that supports server-side rendering
|
||||
|
||||
import { ClientProvider, useAuth } from "bknd/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// setup: extract user from server-side
|
||||
// in your server-side code (e.g., Next.js loader, Astro endpoint):
|
||||
export async function loader({ request }) {
|
||||
// create API instance from your app (may be available in context)
|
||||
const api = app.getApi({ request }); // extracts credentials from request
|
||||
// or: const api = app.getApi({ headers: request.headers });
|
||||
|
||||
const user = api.getUser();
|
||||
// optionally: await api.verifyAuth();
|
||||
|
||||
return { user };
|
||||
}
|
||||
|
||||
// in your component:
|
||||
export default function App({ user }) {
|
||||
return (
|
||||
<ClientProvider user={user}>
|
||||
<InnerApp />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerApp() {
|
||||
const auth = useAuth();
|
||||
|
||||
// optionally verify if not already verified
|
||||
useEffect(() => {
|
||||
if (!auth.verified) {
|
||||
auth.verify();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (auth.user) {
|
||||
return (
|
||||
<div>
|
||||
<p>Logged in as {auth.user.email}</p>
|
||||
{/* logout by navigating to the logout endpoint */}
|
||||
<a href="/api/auth/logout">Logout</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// use form-based authentication for full-stack apps
|
||||
return (
|
||||
<form method="post" action="/api/auth/password/login">
|
||||
<input type="email" name="email" placeholder="Email" />
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- No `baseUrl` needed in `ClientProvider` - it automatically uses the same origin
|
||||
- Pass the `user` prop from server-side to avoid an initial unauthenticated state
|
||||
- Use `app.getApi({ request })` or `app.getApi({ headers })` on the server to extract credentials
|
||||
- The logout endpoint (`/api/auth/logout`) clears the session and redirects back
|
||||
- Authentication persists via cookies automatically handled by the framework
|
||||
|
||||
## `useApiQuery()`
|
||||
|
||||
This hook wraps the API class in an SWR hook for convenience. You can use any API endpoint
|
||||
@@ -61,24 +479,34 @@ export default function App() {
|
||||
|
||||
### Props
|
||||
|
||||
- `selector: (api: Api) => FetchPromise`
|
||||
<TypeTable
|
||||
type={{
|
||||
selector: {
|
||||
description: 'A selector function that provides an Api instance and expects an endpoint function to be returned',
|
||||
type: '(api: Api) => FetchPromise',
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
description: 'Optional SWR configuration with additional options',
|
||||
type: 'SWRConfiguration & { enabled?: boolean; refine?: (data: Data) => Data | any }',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
The first parameter is a selector function that provides an Api instance and expects an
|
||||
endpoint function to be returned.
|
||||
**Options properties:**
|
||||
|
||||
- `options`: optional object that inherits from `SWRConfiguration`
|
||||
|
||||
```ts
|
||||
type Options<Data> = import("swr").SWRConfiguration & {
|
||||
enabled?: boolean;
|
||||
refine?: (data: Data) => Data | any;
|
||||
};
|
||||
```
|
||||
|
||||
* `enabled`: Determines whether this hook should trigger a fetch of the data or not.
|
||||
* `refine`: Optional refinement that is called after a response from the API has been
|
||||
|
||||
received. Useful to omit irrelevant data from the response (see example below).
|
||||
<TypeTable
|
||||
type={{
|
||||
enabled: {
|
||||
description: 'Determines whether this hook should trigger a fetch of the data or not',
|
||||
type: 'boolean',
|
||||
},
|
||||
refine: {
|
||||
description: 'Optional refinement function called after a response from the API has been received. Useful to omit irrelevant data from the response',
|
||||
type: '(data: Data) => Data | any',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Using mutations
|
||||
|
||||
@@ -163,27 +591,46 @@ of entities instead of a single entry.
|
||||
|
||||
### Props
|
||||
|
||||
Following props are available when using `useEntityQuery([entity], [id?])`:
|
||||
|
||||
- `entity: string`: Specify the table name of the entity
|
||||
- `id?: number | string`: If an id given, it will fetch a single entry, otherwise a list
|
||||
<TypeTable
|
||||
type={{
|
||||
entity: {
|
||||
description: 'The table name of the entity',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
description: 'Optional ID. If provided, operations target a single entry; otherwise a list',
|
||||
type: 'number | string',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Returned actions
|
||||
|
||||
The following actions are returned from this hook:
|
||||
|
||||
- `create: (input: object)`: Create a new entry
|
||||
- `read: (query: Partial<RepoQuery> = {})`: If an id was given,
|
||||
it returns a single item, otherwise a list
|
||||
- `update: (input: object, id?: number | string)`: If an id was given, the id parameter is
|
||||
optional. Updates the given entry partially.
|
||||
- `_delete: (id?: number | string)`: If an id was given, the id parameter is
|
||||
optional. Deletes the given entry.
|
||||
<TypeTable
|
||||
type={{
|
||||
create: {
|
||||
description: 'Create a new entry',
|
||||
type: '(input: object) => Promise<Response>',
|
||||
},
|
||||
read: {
|
||||
description: 'If an id was given, returns a single item; otherwise returns a list',
|
||||
type: '(query?: RepoQueryIn) => Promise<Response>',
|
||||
},
|
||||
update: {
|
||||
description: 'Update an entry partially. If an id was given to the hook, the id parameter is optional',
|
||||
type: '(input: object, id?: number | string) => Promise<Response>',
|
||||
},
|
||||
_delete: {
|
||||
description: 'Delete an entry. If an id was given to the hook, the id parameter is optional',
|
||||
type: '(id?: number | string) => Promise<Response>',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
## `useEntityQuery()`
|
||||
|
||||
This hook wraps the actions from `useEntity` around `SWR`. The previous example would look like
|
||||
this:
|
||||
This hook wraps the actions from `useEntity` around `SWR` for automatic data fetching, caching, and revalidation. It combines the power of SWR with CRUD operations for your entities.
|
||||
|
||||
```tsx
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
@@ -195,10 +642,207 @@ export default function App() {
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The returned CRUD actions are typed differently based on whether you provide an `id`:
|
||||
|
||||
- **With `id`** (single item mode): `update` and `_delete` don't require an `id` parameter since the item is already specified
|
||||
- **Without `id`** (list mode): `update` and `_delete` require an `id` parameter to specify which item to modify
|
||||
|
||||
```tsx
|
||||
// single item mode - id is already specified
|
||||
const { data, update, _delete } = useEntityQuery("comments", 1);
|
||||
await update({ content: "new text" }); // no id needed
|
||||
await _delete(); // no id needed
|
||||
|
||||
// list mode - must specify which item to update/delete
|
||||
const { data, update, _delete } = useEntityQuery("comments");
|
||||
await update({ content: "new text" }, 1); // id required
|
||||
await _delete(1); // id required
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
entity: {
|
||||
description: 'The table name of the entity',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
description: 'Optional ID. If provided, fetches a single entry; otherwise fetches a list',
|
||||
type: 'number | string',
|
||||
},
|
||||
query: {
|
||||
description: 'Optional query parameters for filtering, sorting, pagination, etc.',
|
||||
type: 'RepoQueryIn',
|
||||
},
|
||||
options: {
|
||||
description: 'Optional SWR configuration plus additional options',
|
||||
type: 'SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
The `query` parameter accepts a `RepoQueryIn` object with the following options:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
limit: {
|
||||
description: 'Limit the number of results',
|
||||
type: 'number',
|
||||
default: '10',
|
||||
},
|
||||
offset: {
|
||||
description: 'Skip a number of results for pagination',
|
||||
type: 'number',
|
||||
default: '0',
|
||||
},
|
||||
sort: {
|
||||
description: 'Sort by field(s). Prefix with - for descending order (e.g., "-id" or ["name", "-createdAt"])',
|
||||
type: 'string | string[]',
|
||||
default: 'id',
|
||||
},
|
||||
where: {
|
||||
description: 'Filter conditions using query operators (e.g., { status: "active", views: { $gt: 100 } })',
|
||||
type: 'object',
|
||||
},
|
||||
with: {
|
||||
description: 'Include related entities',
|
||||
type: 'string[]',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
#### Options
|
||||
|
||||
The `options` parameter extends SWR's configuration and adds bknd-specific options:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
enabled: {
|
||||
description: 'If false, prevents the query from running (useful for conditional fetching)',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
},
|
||||
revalidateOnMutate: {
|
||||
description: "If false, mutations won't automatically trigger revalidation",
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
},
|
||||
keepPreviousData: {
|
||||
description: 'Keeps showing previous data while fetching new data',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
},
|
||||
revalidateOnFocus: {
|
||||
description: 'Controls whether to revalidate when window regains focus',
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
All standard [SWR configuration options](https://swr.vercel.app/docs/api) are also supported.
|
||||
|
||||
### Return Values
|
||||
|
||||
The hook returns an object with the following properties:
|
||||
|
||||
**SWR Properties:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
data: {
|
||||
description: 'The fetched data (single entity or array of entities)',
|
||||
type: 'Entity | Entity[]',
|
||||
},
|
||||
error: {
|
||||
description: 'Error object if the request failed',
|
||||
type: 'Error',
|
||||
},
|
||||
isLoading: {
|
||||
description: 'True when the initial request is in progress',
|
||||
type: 'boolean',
|
||||
},
|
||||
isValidating: {
|
||||
description: 'True when a request or revalidation is in progress',
|
||||
type: 'boolean',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**CRUD Actions (auto-wrapped with cache revalidation):**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
create: {
|
||||
description: 'Create a new entry',
|
||||
type: '(input: object) => Promise<Response>',
|
||||
},
|
||||
update: {
|
||||
description: 'Update an entry. When id is provided to the hook (single item mode), the id parameter is optional and defaults to the hook id. When no id is provided to the hook (list mode), the id parameter is required',
|
||||
type: '(input: object, id?: number | string) => Promise<Response>',
|
||||
},
|
||||
_delete: {
|
||||
description: 'Delete an entry. When id is provided to the hook (single item mode), the id parameter is optional and defaults to the hook id. When no id is provided to the hook (list mode), the id parameter is required',
|
||||
type: '(id?: number | string) => Promise<Response>',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**Cache Management:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
mutate: {
|
||||
description: 'Manually invalidate and revalidate cache for this entity',
|
||||
type: '(id?: number | string) => Promise<void>',
|
||||
},
|
||||
mutateRaw: {
|
||||
description: "Direct access to SWR's mutate function for advanced use cases",
|
||||
type: 'SWRResponse["mutate"]',
|
||||
},
|
||||
key: {
|
||||
description: 'The SWR cache key being used',
|
||||
type: 'string',
|
||||
},
|
||||
api: {
|
||||
description: 'Direct access to the data API instance',
|
||||
type: 'Api["data"]',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Query Example
|
||||
|
||||
Fetching a limited, sorted list of entities:
|
||||
|
||||
```tsx
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
|
||||
export default function TodoList() {
|
||||
const { data: todos, isLoading } = useEntityQuery("todos", undefined, {
|
||||
limit: 5,
|
||||
sort: "-id", // descending by id
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{todos?.map((todo) => (
|
||||
<li key={todo.id}>{todo.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using mutations
|
||||
|
||||
All actions returned from `useEntityQuery` are conveniently wrapped around the `mutate` function,
|
||||
so you don't have think about this:
|
||||
All actions returned from `useEntityQuery` are conveniently wrapped to automatically revalidate the cache after mutations:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -239,3 +883,195 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complete CRUD Example
|
||||
|
||||
Here's a comprehensive example showing all CRUD operations with query parameters:
|
||||
|
||||
```tsx
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
|
||||
export default function TodoList() {
|
||||
const { data: todos, create, update, _delete, isLoading } = useEntityQuery(
|
||||
"todos",
|
||||
undefined, // no id, so we fetch a list
|
||||
{
|
||||
limit: 10,
|
||||
sort: "-id", // newest first
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
action={async (formData: FormData) => {
|
||||
const title = formData.get("title") as string;
|
||||
await create({ title, done: false });
|
||||
}}
|
||||
>
|
||||
<input type="text" name="title" placeholder="New todo" />
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
{todos?.map((todo) => (
|
||||
<li key={todo.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!todo.done}
|
||||
onChange={async () => {
|
||||
await update({ done: !todo.done }, todo.id);
|
||||
}}
|
||||
/>
|
||||
<span>{todo.title}</span>
|
||||
<button onClick={() => _delete(todo.id)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation Behavior
|
||||
|
||||
**Important notes about mutations:**
|
||||
|
||||
- **Auto-revalidation**: By default, all mutations (`create`, `update`, `_delete`) automatically revalidate all queries for that entity. This ensures your UI stays in sync.
|
||||
|
||||
- **Optimistic updates**: For more advanced scenarios, you can use `mutateRaw` to implement optimistic updates manually.
|
||||
|
||||
- **Disable auto-revalidation**: If you need more control, set `revalidateOnMutate: false`:
|
||||
|
||||
```tsx
|
||||
const { data, update } = useEntityQuery("comments", 1, undefined, {
|
||||
revalidateOnMutate: false,
|
||||
});
|
||||
|
||||
// now update won't trigger automatic revalidation
|
||||
await update({ content: "new text" });
|
||||
```
|
||||
|
||||
- **Manual revalidation**: Use the returned `mutate` function to manually trigger revalidation:
|
||||
|
||||
```tsx
|
||||
const { mutate } = useEntityQuery("comments");
|
||||
|
||||
// revalidate all "comments" queries
|
||||
await mutate();
|
||||
|
||||
// revalidate specific comment
|
||||
await mutate(commentId);
|
||||
```
|
||||
|
||||
## Utility Hooks
|
||||
|
||||
### `useInvalidate()`
|
||||
|
||||
This hook provides a convenient way to invalidate SWR cache entries for manual revalidation.
|
||||
|
||||
```tsx
|
||||
import { useInvalidate } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
const handleRefresh = async () => {
|
||||
// invalidate by string key prefix
|
||||
await invalidate("/data/comments");
|
||||
|
||||
// or invalidate using API selector
|
||||
await invalidate((api) => api.data.readMany("comments"));
|
||||
};
|
||||
|
||||
return <button onClick={handleRefresh}>Refresh Comments</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
options: {
|
||||
description: 'Configuration options',
|
||||
type: '{ exact?: boolean }',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**Options properties:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
exact: {
|
||||
description: 'If true, only invalidates the exact key match instead of keys that start with the given prefix',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### `useEntityMutate()`
|
||||
|
||||
This hook provides mutation actions without fetching data. Useful when you only need to perform CRUD operations without subscribing to data updates.
|
||||
|
||||
```tsx
|
||||
import { useEntityMutate } from "bknd/client";
|
||||
|
||||
export default function QuickActions() {
|
||||
const { create, update, _delete, mutate } = useEntityMutate("todos");
|
||||
|
||||
const createTodo = async () => {
|
||||
await create({ title: "New todo", done: false });
|
||||
// manually update cache
|
||||
await mutate();
|
||||
};
|
||||
|
||||
return <button onClick={createTodo}>Quick Add Todo</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
entity: {
|
||||
description: 'The table name of the entity',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
description: 'Optional ID for single entity operations',
|
||||
type: 'number | string',
|
||||
},
|
||||
options: {
|
||||
description: 'Optional SWR configuration',
|
||||
type: 'SWRConfiguration',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**Return Values:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
create: {
|
||||
description: 'Create a new entry',
|
||||
type: '(input: object) => Promise<Response>',
|
||||
},
|
||||
update: {
|
||||
description: 'Update an entry',
|
||||
type: '(input: object, id?: number | string) => Promise<Response>',
|
||||
},
|
||||
_delete: {
|
||||
description: 'Delete an entry',
|
||||
type: '(id?: number | string) => Promise<Response>',
|
||||
},
|
||||
mutate: {
|
||||
description: 'Function to update cache with partial data without refetching',
|
||||
type: '(id: number | string, data: Partial<Entity>) => Promise<void>',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -137,6 +137,35 @@ To retrieve a single record from an entity, use the `readOne` method:
|
||||
const { data } = await api.data.readOne("posts", 1);
|
||||
```
|
||||
|
||||
### `data.readOneBy([entity], [query])`
|
||||
|
||||
To retrieve a single record from an entity using a query (e.g., by a specific field value) instead of an ID, use the `readOneBy` method:
|
||||
|
||||
```ts
|
||||
const { data } = await api.data.readOneBy("posts", {
|
||||
where: {
|
||||
slug: "hello-world",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This is useful when you want to find a record by a unique field other than the primary key. You can also use `select`, `with`, and `join` options:
|
||||
|
||||
```ts
|
||||
const { data } = await api.data.readOneBy("users", {
|
||||
select: ["id", "email", "name"],
|
||||
where: {
|
||||
email: "user@example.com",
|
||||
},
|
||||
with: {
|
||||
posts: {
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
join: ["profile"],
|
||||
});
|
||||
```
|
||||
|
||||
### `data.createOne([entity], [data])`
|
||||
|
||||
To create a single record of an entity, use the `createOne` method:
|
||||
@@ -266,6 +295,16 @@ const { data } = await api.auth.register("password", {
|
||||
});
|
||||
```
|
||||
|
||||
### `auth.logout()`
|
||||
|
||||
To log out the current user and clear the stored token, use the `logout` method:
|
||||
|
||||
```ts
|
||||
await api.auth.logout();
|
||||
```
|
||||
|
||||
This method takes no parameters. It sends a request to the logout endpoint and clears the authentication token. Returns a `Promise<void>`.
|
||||
|
||||
### `auth.me()`
|
||||
|
||||
To retrieve the current user, use the `me` method:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
*.db
|
||||
static/manifest.json
|
||||
static/assets
|
||||
@@ -0,0 +1,26 @@
|
||||
# bknd + SvelteKit Example
|
||||
|
||||
This example shows how to integrate bknd with SvelteKit.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. **`bknd.config.ts`** - bknd configuration with database connection, schema, and seed data
|
||||
2. **`src/hooks.server.ts`** - Routes `/api/*` requests to bknd
|
||||
3. **`src/routes/+page.server.ts`** - Uses `getApp()` to fetch data server-side
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/data/entity/todos` - List todos (requires auth)
|
||||
- `POST /api/auth/password/login` - Login
|
||||
|
||||
## Test Credentials
|
||||
|
||||
- Email: `admin@example.com`
|
||||
- Password: `password`
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { SvelteKitBkndConfig } from "bknd/adapter/sveltekit";
|
||||
import { em, entity, text, libsql } from "bknd";
|
||||
import { createClient } from "@libsql/client";
|
||||
|
||||
const schema = em({
|
||||
todos: entity("todos", {
|
||||
title: text().required(),
|
||||
done: text(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default {
|
||||
connection: libsql(
|
||||
createClient({
|
||||
url: "file:data.db",
|
||||
})
|
||||
),
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
auth: {
|
||||
enabled: true,
|
||||
allow_register: true,
|
||||
jwt: {
|
||||
issuer: "bknd-sveltekit-example",
|
||||
secret: "dev-secret-change-in-production-1234567890abcdef",
|
||||
},
|
||||
roles: {
|
||||
admin: {
|
||||
implicit_allow: true,
|
||||
},
|
||||
default: {
|
||||
permissions: ["data.entity.read", "data.entity.create"],
|
||||
is_default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
adminOptions: {
|
||||
// this path must be the same as in `hooks.server.ts`
|
||||
adminBasepath: "/admin"
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx) => {
|
||||
await ctx.app.module.auth.createUser({
|
||||
email: "admin@example.com",
|
||||
password: "password",
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
await ctx.em.mutator("todos").insertMany([
|
||||
{ title: "Learn bknd", done: "true" },
|
||||
{ title: "Build with SvelteKit", done: "false" },
|
||||
]);
|
||||
},
|
||||
},
|
||||
} as const satisfies SvelteKitBkndConfig;
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "bknd-sveltekit-example",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"postinstall": "node node_modules/.bin/bknd copy-assets --out static"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bknd": "file:../../app",
|
||||
"@libsql/client": "^0.15.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
import { serve } from "bknd/adapter/sveltekit";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import config from "../bknd.config";
|
||||
|
||||
const bkndHandler = serve(config, env);
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Handle bknd API requests
|
||||
const pathname = event.url.pathname;
|
||||
if (pathname.startsWith("/api/") || pathname.startsWith("/admin")) {
|
||||
const res = await bkndHandler(event);
|
||||
if (res.status !== 404) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getApp } from "bknd/adapter/sveltekit";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import config from "../../bknd.config";
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const app = await getApp(config, env);
|
||||
const api = app.getApi();
|
||||
|
||||
const todos = await api.data.readMany("todos");
|
||||
|
||||
return {
|
||||
todos: todos.data ?? [],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<h1>bknd + SvelteKit Example</h1>
|
||||
|
||||
<h2>Todos</h2>
|
||||
<ul>
|
||||
{#each data.todos as todo (todo.id)}
|
||||
<li>{todo.title} - {todo.done === "true" ? "Done" : "Pending"}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<h2>API Endpoints</h2>
|
||||
<ul>
|
||||
<li><a href="/api/data/entity/todos">/api/data/entity/todos</a></li>
|
||||
<li><a href="/api/auth/password/login">/api/auth/password/login</a> (POST)</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Login credentials: <code>admin@example.com</code> / <code>password</code>
|
||||
</p>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,12 @@
|
||||
import adapter from "@sveltejs/adapter-auto";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
});
|
||||
Reference in New Issue
Block a user