acrylblends 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/index.js +5 -1
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/README.md +6 -4
  4. package/docs/framework/UPSTREAM.md +1 -1
  5. package/docs/framework/getting-started/aws-lambda.md +115 -0
  6. package/docs/framework/getting-started/bun.md +1 -3
  7. package/docs/framework/getting-started/cloudflare-pages.md +53 -0
  8. package/docs/framework/getting-started/cloudflare-workers.md +67 -0
  9. package/docs/framework/getting-started/deploy.md +5 -5
  10. package/docs/framework/getting-started/lambda-edge.md +41 -0
  11. package/docs/framework/getting-started/netlify.md +25 -0
  12. package/docs/framework/getting-started/other-runtimes.md +20 -9
  13. package/docs/framework/getting-started/service-worker.md +33 -0
  14. package/docs/framework/getting-started/vercel.md +28 -0
  15. package/docs/framework/guides/create-acrylblends.md +40 -2
  16. package/docs/framework/guides/data.md +202 -0
  17. package/docs/framework/guides/faq.md +2 -2
  18. package/docs/framework/guides/forms-and-sessions.md +34 -30
  19. package/docs/framework/helpers/conninfo.md +1 -1
  20. package/docs/framework/helpers/ssg.md +1 -1
  21. package/docs/framework/helpers/streaming.md +3 -2
  22. package/docs/framework/helpers/websocket.md +2 -2
  23. package/docs/framework/middleware/builtin/ip-restriction.md +1 -1
  24. package/docs/framework/upstream.json +3 -3
  25. package/lib/aws-lambda.d.ts +324 -0
  26. package/lib/aws-lambda.js +357 -0
  27. package/lib/bun.d.ts +75 -0
  28. package/lib/bun.js +134 -0
  29. package/lib/cloudflare-pages.d.ts +58 -0
  30. package/lib/cloudflare-pages.js +78 -0
  31. package/lib/cloudflare-workers.d.ts +21 -0
  32. package/lib/cloudflare-workers.js +80 -0
  33. package/lib/db/sqlite.d.ts +27 -0
  34. package/lib/db/sqlite.js +197 -0
  35. package/lib/db.d.ts +2 -0
  36. package/lib/db.js +2 -0
  37. package/lib/index-CseujaYI.d.ts +31 -0
  38. package/lib/index-D4FAsBZu.d.ts +85 -0
  39. package/lib/index-DaJtL7ci.d.ts +156 -0
  40. package/lib/lambda-edge.d.ts +92 -0
  41. package/lib/lambda-edge.js +105 -0
  42. package/lib/netlify.d.ts +29 -0
  43. package/lib/netlify.js +30 -0
  44. package/lib/node-server/register.js +1 -1
  45. package/lib/node-server/serve-static.js +1 -1
  46. package/lib/node-server.js +3 -3
  47. package/lib/serve-static-Cp5adzGo.js +77 -0
  48. package/lib/serve-static.d.ts +1 -30
  49. package/lib/serve-static.js +1 -76
  50. package/lib/service-worker.d.ts +48 -0
  51. package/lib/service-worker.js +38 -0
  52. package/lib/src-MWcsMS8i.js +85 -0
  53. package/lib/ssg.d.ts +1 -155
  54. package/lib/vercel.d.ts +10 -0
  55. package/lib/vercel.js +9 -0
  56. package/package.json +41 -1
  57. package/plugins/acryl-db/LICENSE +47 -0
  58. package/plugins/acryl-db/plugin.json +12 -0
  59. package/plugins/acryl-db/src/cordis.ts +13 -0
  60. package/plugins/acryl-db/src/facade/database.ts +41 -0
  61. package/plugins/acryl-db/src/facade/migrations.ts +50 -0
  62. package/plugins/acryl-db/src/facade/schema.ts +9 -0
  63. package/plugins/acryl-db/src/index.ts +19 -0
  64. package/plugins/acryl-db/src/migration-files.ts +31 -0
  65. package/plugins/acryl-db/upstream.json +18 -0
  66. package/plugins/acryl-db-sqlite/LICENSE +47 -0
  67. package/plugins/acryl-db-sqlite/plugin.json +13 -0
  68. package/plugins/acryl-db-sqlite/src/facade/node.ts +199 -0
  69. package/plugins/acryl-db-sqlite/src/index.ts +88 -0
  70. package/plugins/acryl-db-sqlite/src/open.ts +61 -0
  71. package/plugins/acryl-db-sqlite/upstream.json +21 -0
  72. package/plugins/acryl-inspect/upstream.json +1 -1
  73. package/plugins/acryl-node-server/upstream.json +1 -1
  74. package/plugins/acryl-router/src/core/adapter/aws-lambda/conninfo.ts +73 -0
  75. package/plugins/acryl-router/src/core/adapter/aws-lambda/handler.ts +682 -0
  76. package/plugins/acryl-router/src/core/adapter/aws-lambda/index.ts +14 -0
  77. package/plugins/acryl-router/src/core/adapter/aws-lambda/types.ts +173 -0
  78. package/plugins/acryl-router/src/core/adapter/bun/conninfo.ts +43 -0
  79. package/plugins/acryl-router/src/core/adapter/bun/index.ts +11 -0
  80. package/plugins/acryl-router/src/core/adapter/bun/serve-static.ts +32 -0
  81. package/plugins/acryl-router/src/core/adapter/bun/server.ts +14 -0
  82. package/plugins/acryl-router/src/core/adapter/bun/ssg.ts +26 -0
  83. package/plugins/acryl-router/src/core/adapter/bun/websocket.ts +113 -0
  84. package/plugins/acryl-router/src/core/adapter/cloudflare-pages/conninfo.ts +26 -0
  85. package/plugins/acryl-router/src/core/adapter/cloudflare-pages/handler.ts +122 -0
  86. package/plugins/acryl-router/src/core/adapter/cloudflare-pages/index.ts +11 -0
  87. package/plugins/acryl-router/src/core/adapter/cloudflare-workers/conninfo.ts +7 -0
  88. package/plugins/acryl-router/src/core/adapter/cloudflare-workers/index.ts +8 -0
  89. package/plugins/acryl-router/src/core/adapter/cloudflare-workers/serve-static-module.ts +12 -0
  90. package/plugins/acryl-router/src/core/adapter/cloudflare-workers/serve-static.ts +41 -0
  91. package/plugins/acryl-router/src/core/adapter/cloudflare-workers/utils.ts +50 -0
  92. package/plugins/acryl-router/src/core/adapter/cloudflare-workers/websocket.ts +52 -0
  93. package/plugins/acryl-router/src/core/adapter/lambda-edge/conninfo.ts +15 -0
  94. package/plugins/acryl-router/src/core/adapter/lambda-edge/handler.ts +239 -0
  95. package/plugins/acryl-router/src/core/adapter/lambda-edge/index.ts +14 -0
  96. package/plugins/acryl-router/src/core/adapter/netlify/conninfo.ts +57 -0
  97. package/plugins/acryl-router/src/core/adapter/netlify/handler.ts +10 -0
  98. package/plugins/acryl-router/src/core/adapter/netlify/index.ts +6 -0
  99. package/plugins/acryl-router/src/core/adapter/netlify/mod.ts +2 -0
  100. package/plugins/acryl-router/src/core/adapter/service-worker/handler.ts +37 -0
  101. package/plugins/acryl-router/src/core/adapter/service-worker/index.ts +36 -0
  102. package/plugins/acryl-router/src/core/adapter/service-worker/types.ts +14 -0
  103. package/plugins/acryl-router/src/core/adapter/vercel/conninfo.ts +8 -0
  104. package/plugins/acryl-router/src/core/adapter/vercel/handler.ts +8 -0
  105. package/plugins/acryl-router/src/core/adapter/vercel/index.ts +7 -0
  106. package/plugins/acryl-router/upstream.json +2 -2
  107. package/plugins/acryl-router-http/upstream.json +1 -1
  108. package/plugins/acryl-state/upstream.json +1 -1
  109. package/plugins/desktop/upstream.json +1 -1
  110. package/plugins/desktop-web/main.ts +13 -3
  111. package/plugins/desktop-web/upstream.json +1 -1
  112. package/plugins/tsconfig.json +1 -0
  113. package/templates/todo/upstream.json +1 -1
  114. package/lib/{serve-static-B14MF1Va.js → serve-static-B849HlX2.js} +1 -1
  115. package/lib/{transpile-CTnWof4z.js → transpile-QUK9b3eG.js} +1 -1
@@ -0,0 +1,41 @@
1
+ # Lambda@Edge
2
+
3
+ An Acryl app can answer CloudFront requests from Lambda@Edge. `handle(app)` from `acrylblends/lambda-edge` turns the app into the edge function: it builds a `Request` from the CloudFront event, calls `app.fetch`, and returns a CloudFront response. A viewer-request or origin-request function that answers by itself (a redirect, an HTML page, a JSON answer) is the typical use.
4
+
5
+ > **Status.** The adapter is the upstream adapter ported with its test suite, and the framework's release check calls it with a CloudFront viewer-request event. It has not been deployed to AWS by the ACRYL team.
6
+
7
+ ```ts
8
+ import { Acryl } from 'acrylblends'
9
+ import { handle } from 'acrylblends/lambda-edge'
10
+
11
+ const app = new Acryl()
12
+
13
+ app.get('/', (c) => c.text('Hello from the edge'))
14
+
15
+ export const handler = handle(app)
16
+ ```
17
+
18
+ Lambda@Edge has its own limits: the function must be created in `us-east-1`, has no environment variables, and its size and time limits are lower than Lambda's; check them in the AWS documentation when you choose between this adapter and [AWS Lambda](aws-lambda.md).
19
+
20
+ ## The CloudFront event
21
+
22
+ The CloudFront request, response and config are in the bindings:
23
+
24
+ ```ts
25
+ import { Acryl } from 'acrylblends'
26
+ import type { Callback, CloudFrontEdgeEvent } from 'acrylblends/lambda-edge'
27
+
28
+ type Bindings = {
29
+ callback: Callback
30
+ event: CloudFrontEdgeEvent
31
+ }
32
+
33
+ const app = new Acryl<{ Bindings: Bindings }>()
34
+
35
+ app.get('/country', (c) => {
36
+ const request = c.env.event.Records[0].cf.request
37
+ return c.json({ uri: request.uri, clientIp: request.clientIp })
38
+ })
39
+ ```
40
+
41
+ The client's address is available through the [ConnInfo helper](../helpers/conninfo.md).
@@ -0,0 +1,25 @@
1
+ # Netlify
2
+
3
+ Netlify Functions (version 2) and Edge Functions take a `Request` and a context and return a `Response`. `handle(app)` from `acrylblends/netlify` is that function; the Netlify context is available to the app as `c.env.context`.
4
+
5
+ > **Status.** The adapter is the upstream adapter ported with its test suite, and the framework's release check calls it with a `Request`. It has not been deployed to Netlify by the ACRYL team.
6
+
7
+ ## A function
8
+
9
+ `netlify/functions/api.ts`:
10
+
11
+ ```ts
12
+ import { Acryl } from 'acrylblends'
13
+ import { handle } from 'acrylblends/netlify'
14
+ import type { Context } from '@netlify/functions'
15
+
16
+ type Bindings = { context: Context }
17
+
18
+ const app = new Acryl<{ Bindings: Bindings }>().basePath('/.netlify/functions/api')
19
+
20
+ app.get('/hello', (c) => c.json({ hello: 'world', site: c.env.context.site.id }))
21
+
22
+ export default handle(app)
23
+ ```
24
+
25
+ Run it locally with `netlify dev` and publish with `netlify deploy`. The client's address comes from `getConnInfo` of `acrylblends/netlify` (the Netlify context's `ip`); see the [ConnInfo helper](../helpers/conninfo.md).
@@ -1,23 +1,34 @@
1
1
  # Other runtimes
2
2
 
3
- Acryl is built on Web Standards, so the same application code is expected to run wherever a `fetch`-style handler can run. Only Node.js ([guide](nodejs.md)) is verified so far. Bun is experimental ([guide](bun.md)).
3
+ Acryl is built on Web Standards, so the same application code is expected to run wherever a `fetch`-style handler can run. Node.js ([guide](nodejs.md)) is the runtime the framework is verified on. Bun is experimental ([guide](bun.md)).
4
4
 
5
- The following runtimes and platforms are **not yet verified**. There are no getting-started pages for them yet, and their adapters (for example `acrylblends/cloudflare-workers`
6
- or `acrylblends/aws-lambda`) are not part of acrylblends yet:
5
+ ## Hosting platforms with an adapter
6
+
7
+ These platforms have an adapter in acrylblends, ported from the upstream adapters with their tests. Each is called with the platform's own events in the framework's release check; the ACRYL team has not deployed to these platforms, so try a first deploy on a test project.
8
+
9
+ | Platform | Guide | Import |
10
+ | --- | --- | --- |
11
+ | AWS Lambda (API Gateway, ALB, Function URLs, VPC Lattice) | [AWS Lambda](aws-lambda.md) | `acrylblends/aws-lambda` |
12
+ | Lambda@Edge (CloudFront) | [Lambda@Edge](lambda-edge.md) | `acrylblends/lambda-edge` |
13
+ | Cloudflare Workers | [Cloudflare Workers](cloudflare-workers.md) | `acrylblends/cloudflare-workers` |
14
+ | Cloudflare Pages | [Cloudflare Pages](cloudflare-pages.md) | `acrylblends/cloudflare-pages` |
15
+ | Vercel | [Vercel](vercel.md) | `acrylblends/vercel` |
16
+ | Netlify | [Netlify](netlify.md) | `acrylblends/netlify` |
17
+ | Service Worker | [Service Worker](service-worker.md) | `acrylblends/service-worker` |
18
+ | Bun | [Bun](bun.md) | `acrylblends/bun` |
19
+
20
+ ## Not yet verified
21
+
22
+ The following runtimes and platforms have no adapter and no getting-started page yet:
7
23
 
8
24
  - Deno, and Deno Deploy
9
- - Cloudflare Workers, and Cloudflare Pages
10
25
  - Fastly Compute
11
- - AWS Lambda, and Lambda@Edge
12
- - Vercel
13
- - Netlify
14
26
  - Next.js
15
27
  - Supabase Functions
16
28
  - Google Cloud Run
17
29
  - Azure Functions
18
30
  - Alibaba Cloud Function Compute
19
31
  - CloudBase
20
- - Service Worker
21
32
  - WebAssembly (WASI)
22
33
 
23
- A page is added here, with a getting-started guide, when a platform has been verified with a test that runs on it.
34
+ A page is added here, with a getting-started guide, when a platform has an adapter and a test that calls it.
@@ -0,0 +1,33 @@
1
+ # Service Worker
2
+
3
+ An Acryl app can answer `fetch` events in a Service Worker, and in any environment that uses the Service Worker syntax, such as a Cloudflare Worker written without ES modules. `fire(app)` from `acrylblends/service-worker` registers the app on the `fetch` event, and `handle(app)` returns the listener, if you want to register it yourself.
4
+
5
+ > **Status.** The adapter is the upstream adapter ported with its test suite, and the framework's release check calls it with a fetch event. It has not been run inside a browser's Service Worker by the ACRYL team.
6
+
7
+ ```ts
8
+ import { Acryl } from 'acrylblends'
9
+ import { fire } from 'acrylblends/service-worker'
10
+
11
+ const app = new Acryl()
12
+
13
+ app.get('/', (c) => c.text('Hello from a Service Worker'))
14
+
15
+ fire(app)
16
+ ```
17
+
18
+ A request the app does not answer (`404`) can be passed on to the network by giving `fetch` as an option, so a Service Worker can serve some routes and leave the others to the origin:
19
+
20
+ ```ts lib=webworker
21
+ import { Acryl } from 'acrylblends'
22
+ import { handle } from 'acrylblends/service-worker'
23
+
24
+ declare const self: ServiceWorkerGlobalScope
25
+
26
+ const app = new Acryl()
27
+
28
+ app.get('/offline', (c) => c.text('Served by the Service Worker'))
29
+
30
+ self.addEventListener('fetch', handle(app, { fetch: globalThis.fetch }))
31
+ ```
32
+
33
+ The example is type-checked with the TypeScript `WebWorker` library: declare `self` as a `ServiceWorkerGlobalScope` as above. `app.fire()` (a method of the app) is deprecated in favour of `fire()` from this module.
@@ -0,0 +1,28 @@
1
+ # Vercel
2
+
3
+ Vercel runs a function that takes a `Request` and returns a `Response`, which is what `app.fetch` is. `handle(app)` from `acrylblends/vercel` is that function.
4
+
5
+ > **Status.** The adapter is the upstream adapter ported with its test suite, and the framework's release check calls it with a `Request`. It has not been deployed to Vercel by the ACRYL team.
6
+
7
+ ## A function
8
+
9
+ Create `api/index.ts`:
10
+
11
+ ```ts
12
+ import { Acryl } from 'acrylblends'
13
+ import { handle } from 'acrylblends/vercel'
14
+
15
+ const app = new Acryl().basePath('/api')
16
+
17
+ app.get('/', (c) => c.text('Hello from Vercel'))
18
+ app.get('/hello', (c) => c.json({ hello: 'world' }))
19
+
20
+ export const GET = handle(app)
21
+ export const POST = handle(app)
22
+ ```
23
+
24
+ Export one handler per HTTP method the app answers. Run it locally with `vercel dev`, and publish with `vercel deploy`.
25
+
26
+ ## The client's address
27
+
28
+ The client's address comes from `getConnInfo` of `acrylblends/vercel`, which reads the `x-real-ip` header Vercel sets; see the [ConnInfo helper](../helpers/conninfo.md).
@@ -8,6 +8,8 @@
8
8
  | Template | What you get |
9
9
  | --- | --- |
10
10
  | `nodejs` | an Acryl application served with `serve()` from `acrylblends/node-server`; run with Node.js 22.19 or newer |
11
+ | `notes` | a full-stack starter: accounts (scrypt, cookie session), notes in SQLite through the [`db` port](data.md) with numbered migrations, validated forms, search, a JSON API with a typed test client, csrf, a test for every route, a client-component counter |
12
+ | `chat` | an AI chat starter: the answer streams token by token from an OpenAI-compatible API (DeepSeek by default, key only from `DEEPSEEK_API_KEY`), a client component with a Stop button that also stops the request to the model, history in SQLite through the [`db` port](data.md), tests with a mock model |
11
13
  | `bun` (experimental) | an Acryl application with `export default app`, run with `bun` |
12
14
  | `acryl-app` | a Cordis app built from plugins, with an agent that can extend it while it runs (the same app `npx acrylblends init --template agent` creates) |
13
15
 
@@ -47,14 +49,50 @@ bun create acrylblends@latest my-app --template nodejs
47
49
  | Argument | Description | Example |
48
50
  | :-- | :-- | :-- |
49
51
  | `[directory]` | The folder to create. It must not exist or must be empty. Asked for when it is missing. | `my-app` |
50
- | `--template <template>` | One of `nodejs`, `bun` (experimental), `acryl-app`. Skips the template question. | `--template bun` |
51
- | `--install` | Install the dependencies with the package manager after creating the project. | `--install` |
52
+ | `[source]` | A template from outside the package: `github:owner/repo[#ref]`, `@scope/name[@version]` or a packed `.tgz` file. See [Templates from a repository or a package](#templates-from-a-repository-or-a-package). | `github:acme/starter` |
53
+ | `--template <template>` | One of `nodejs`, `notes`, `chat`, `bun` (experimental), `acryl-app`. Skips the template question. | `--template bun` |
54
+ | `--install` | Install the dependencies with the package manager after creating the project. For a template from a source, the install scripts it will run are listed first. | `--install` |
52
55
  | `--pm <packageManager>` | The package manager for `--install`: `npm`, `pnpm`, `yarn` or `bun`. Defaults to the one that started the initializer. | `--pm pnpm` |
53
56
  | `--yes`, `-y` | Ask nothing: use `nodejs` unless `--template` is given, and the directory `acryl-app`. | `-y` |
54
57
  | `--help` | Print the arguments. | `--help` |
55
58
 
56
59
  The templates are inside the package, so creating a project needs no network except for `--install`.
57
60
 
61
+ ## Templates from a repository or a package
62
+
63
+ A second argument after the directory makes the app from someone else's template:
64
+
65
+ ```sh
66
+ npm create acrylblends@latest my-app github:acme/starter # a GitHub repository (the default branch)
67
+ npm create acrylblends@latest my-app github:acme/starter#v1.2 # a tag, branch or commit
68
+ npm create acrylblends@latest my-app @acme/starter # an npm package
69
+ npm create acrylblends@latest my-app ./acme-starter-1.0.0.tgz # a packed template on disk
70
+ ```
71
+
72
+ A template is a folder (a repository, or an npm package) with an `acryl.template.json` at its top:
73
+
74
+ ```json
75
+ {
76
+ "name": "starter",
77
+ "description": "What the app is",
78
+ "plugins": ["desktop"]
79
+ }
80
+ ```
81
+
82
+ `name` and `description` are required and shown when the app is created. `plugins` is optional: library plugins the template needs, which the command lists with the `acrylblends add` line that adds them. A source without the file is refused with a message.
83
+
84
+ What the command does, in order:
85
+
86
+ 1. **Fetches without running anything.** A GitHub source is downloaded as a tarball (never cloned, so no git hook can run). An npm source is fetched with `npm pack --ignore-scripts`. The archive is unpacked into a scratch folder; one with a path that leaves its folder, a symbolic link, more than 5,000 files or more than 50 MB is refused.
87
+ 2. **Reads `acryl.template.json`** and refuses the source if it is missing or malformed.
88
+ 3. **Fills the template in.** `__APP_NAME__`, `__PACKAGE__`, `__CLASS__` and `__RANGE__` in text files are replaced as in the built-in templates, `package.json` gets the app's name, a dependency on `acrylblends` is set to the release this command carries, a `gitignore` file becomes `.gitignore` (npm does not publish a `.gitignore`), `docs/framework/` is added, and so are `AGENTS.md` and `CLAUDE.md` when the template has none of its own.
89
+ 4. **Looks for secrets.** The result is checked with the secret scan the save step of Blend apps uses; a template that holds a key or a password is refused, and the file and line are named (never the value).
90
+ 5. **Creates the folder** only if all of that passed.
91
+
92
+ Nothing of the template is installed or run. If its `package.json` defines `preinstall`, `install`, `postinstall` or `prepare`, the command says so and prints them: `npm install` would run them. Read them first. With `--install` the command prints the list of what will be installed and which scripts will run, then runs the install.
93
+
94
+ To publish your own: put `acryl.template.json` in the repository or package, name the files with the placeholders above, keep a file named `gitignore` instead of `.gitignore`, and keep secrets out.
95
+
58
96
  ## Example flows
59
97
 
60
98
  ### Interactive
@@ -0,0 +1,202 @@
1
+ # Data: the `db` port, migrations and choosing a store
2
+
3
+ An app that keeps records (users, notes, orders) keeps them in a SQL database, and reaches it through one small interface, `db`. Handlers and
4
+ stores take a `Db` as an argument and never import a driver, so the file or server behind it can change without touching them. SQLite is the
5
+ provider today; a Postgres provider (Postgres or Supabase through `DATABASE_URL`) is planned and serves the same interface.
6
+
7
+ ## Choosing
8
+
9
+ | The app | Use | State |
10
+ | --- | --- | --- |
11
+ | Normal web or desktop app, users, lists, orders | `db` with SQLite (the `notes` and `chat` templates) | available |
12
+ | Hosted, many users | `db` with Postgres or Supabase | planned |
13
+ | Tiny, one person, a few screens | `kv`, named JSON documents with no SQL | planned |
14
+ | Agents or long tasks that must survive a restart | `durable`, in the same database as `db` | planned |
15
+
16
+ Start with `db`. It is the only one of the four you can use today, and SQL is what people and coding agents already know.
17
+
18
+ ## Open the database
19
+
20
+ `openDb` from `acrylblends/db/sqlite` opens one SQLite file, applies your migrations, and returns the database next to the function that closes it:
21
+
22
+ ```ts
23
+ import { openDb } from 'acrylblends/db/sqlite'
24
+
25
+ const { db, close } = await openDb({ path: 'data/app.sqlite', migrations: 'migrations' })
26
+ ```
27
+
28
+ - `path` is the file, created with its folder when it does not exist. `':memory:'` opens a database that lives and dies with the process, which
29
+ is what tests use.
30
+ - `migrations` is a folder of numbered SQL files, relative to the working directory (see below).
31
+ - `db` is the port: `exec`, `run`, `get`, `all` and `transaction`, every one returning a promise. It has no `close`, so no part of the app can
32
+ close the database another part is using. `close` is the opener's: call it once, when the process stops.
33
+
34
+ Pass `db` to the code that needs it. Nothing opens the database at import time, so importing a module never creates a file:
35
+
36
+ ```ts
37
+ import type { Db } from 'acrylblends/db'
38
+
39
+ export function notesStore(db: Db) {
40
+ return {
41
+ async titles(userId: number): Promise<string[]> {
42
+ const rows = await db.all<{ title: string }>(
43
+ 'SELECT title FROM notes WHERE user_id = ? ORDER BY updated_at DESC',
44
+ userId,
45
+ )
46
+ return rows.map((row) => row.title)
47
+ },
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Migrations
53
+
54
+ The shape of the database lives in `migrations/`, one numbered file per change:
55
+
56
+ ```
57
+ migrations/
58
+ 001-notes.sql
59
+ 002-tags.sql
60
+ ```
61
+
62
+ ```sql
63
+ -- migrations/002-tags.sql
64
+ CREATE TABLE tags (
65
+ note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
66
+ name TEXT NOT NULL,
67
+ PRIMARY KEY (note_id, name)
68
+ );
69
+ ```
70
+
71
+ - Files are named `NNN-name.sql` and numbered from 1 with no gaps. A gap, a repeated number or a file named otherwise stops the app at start
72
+ with a message that names it.
73
+ - On start, `openDb` applies the files that have not run yet, in order, **in one transaction**: a failing file leaves the database as it was.
74
+ - The version is recorded in the `_migrations` table. Do not touch that table.
75
+ - **Never edit a file that has been applied.** A database that already ran `001` does not run it again, so an edit changes only new databases.
76
+ To change a table, add the next file (`ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`).
77
+ - A database whose version is newer than your files (an older build started on newer data) is refused. The app does not start.
78
+ - A migration is plain SQL. Put the data change next to the shape change in the same file when they belong together.
79
+
80
+ To add a feature that needs a column or a table, the work is always the same three steps: write the next migration, change the store that
81
+ reads and writes it, and change the handlers and views that use the store.
82
+
83
+ ## Reading and writing
84
+
85
+ ```ts
86
+ // One row, or undefined.
87
+ const note = await db.get<Note>('SELECT * FROM notes WHERE id = ? AND user_id = ?', id, userId)
88
+
89
+ // Many rows.
90
+ const notes = await db.all<Note>('SELECT * FROM notes WHERE user_id = ? ORDER BY id', userId)
91
+
92
+ // A write: how many rows it changed.
93
+ const { changes } = await db.run('DELETE FROM notes WHERE id = ? AND user_id = ?', id, userId)
94
+ if (changes === 0) return c.notFound()
95
+
96
+ // A write that gives back the row it made.
97
+ const created = await db.get<Note>(
98
+ 'INSERT INTO notes (user_id, title, body, updated_at) VALUES (?, ?, ?, ?) RETURNING *',
99
+ userId, title, body, new Date().toISOString(),
100
+ )
101
+ ```
102
+
103
+ - Values go in as **bindings** after the SQL (`?`, in order), never inside the SQL text. This is what keeps a title like `'); DROP TABLE notes;--`
104
+ a title. A binding is `null`, a number, a bigint, a string or a `Uint8Array`; turn a boolean into `0` or `1` and a date into
105
+ `toISOString()` yourself.
106
+ - `exec(sql)` runs SQL text with no bindings, and may hold several statements. Use it for setup, not for user input.
107
+ - `db.get` has no `lastInsertRowid`. To get the new row or its id, end the insert with `RETURNING *` or `RETURNING id`.
108
+ - `changes` is what makes "update only if nothing changed meanwhile" possible: `UPDATE ... WHERE id = ? AND version = ?` and check `changes`.
109
+
110
+ ## Transactions
111
+
112
+ Changes that must happen together go in `transaction`. Everything inside goes through the handle the callback receives:
113
+
114
+ ```ts
115
+ await db.transaction(async (tx) => {
116
+ const { changes } = await tx.run('UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?', amount, from, amount)
117
+ if (changes === 0) throw new Error('not enough money')
118
+ await tx.run('UPDATE accounts SET balance = balance + ? WHERE id = ?', amount, to)
119
+ })
120
+ ```
121
+
122
+ - If the callback throws, the transaction is rolled back and `transaction` rejects with that same error.
123
+ - Other calls, and other transactions, wait until the transaction finishes. So **never call `db` itself inside the callback**, only `tx`: that
124
+ call would wait for a transaction that waits for it, and never return.
125
+ - The handle stops working when the callback settles. Do not keep it.
126
+
127
+ ## Writing SQL that moves to Postgres
128
+
129
+ The Postgres provider is not released yet, and it will serve the same interface. SQL that stays inside this list runs on both:
130
+
131
+ - Placeholders are `?` on every provider.
132
+ - Use `RETURNING` to get a new row, not a driver-specific "last id".
133
+ - Keep dates as ISO 8601 text (`new Date().toISOString()`), and booleans as `0` and `1`.
134
+ - Keep identifiers lower case and unquoted.
135
+ - `INTEGER PRIMARY KEY` makes an automatically numbered key in SQLite. The identity column of another database is spelled differently, so keep key
136
+ definitions in migration files, where a different provider's version of the file can differ.
137
+ - SQLite lets a column hold a value of another type than it declares; most other databases do not. Store what you declare.
138
+
139
+ ## In tests
140
+
141
+ A test opens its own database in memory, with the same migrations as production, and closes it:
142
+
143
+ ```ts
144
+ import { afterEach, beforeEach, expect, test } from 'vitest'
145
+ import { openDb } from 'acrylblends/db/sqlite'
146
+ import { notesStore } from './db.ts'
147
+
148
+ let close: () => Promise<void>
149
+ let store: ReturnType<typeof notesStore>
150
+
151
+ beforeEach(async () => {
152
+ const opened = await openDb({ path: ':memory:', migrations: 'migrations' })
153
+ close = opened.close
154
+ store = notesStore(opened.db)
155
+ })
156
+ afterEach(() => close())
157
+
158
+ test('a new store has no notes', async () => {
159
+ expect(await store.titles(1)).toEqual([])
160
+ })
161
+ ```
162
+
163
+ ## In production
164
+
165
+ Open the database once at start, serve, and close it when the process is told to stop, after the server has stopped taking requests:
166
+
167
+ ```ts
168
+ import { serve } from 'acrylblends/node-server'
169
+ import { openDb } from 'acrylblends/db/sqlite'
170
+ import { createApp } from './app.ts'
171
+
172
+ const { db, close } = await openDb({ path: process.env.DATABASE_FILE ?? 'data/app.sqlite', migrations: 'migrations' })
173
+ const server = serve({ fetch: createApp(db).fetch, port: Number(process.env.PORT ?? 3000) })
174
+
175
+ process.once('SIGTERM', async () => {
176
+ server.close()
177
+ await close()
178
+ })
179
+ ```
180
+
181
+ `close` waits for the calls and the transaction that are running, flushes the write-ahead log into the main file, and releases the file, so a
182
+ copy of `data/app.sqlite` made afterwards is complete. Keep the file on a disk that outlives a deploy; on a host with only an ephemeral disk the
183
+ data goes with the machine.
184
+
185
+ ## In a Cordis app
186
+
187
+ An ACRYL app gets the same interface as a service. The `acryl-db-sqlite` plugin row opens the file (by default `data.sqlite` in the app home),
188
+ applies the `migrations` folder, and provides `db`; a plugin declares `inject: ['db']` and imports the types only:
189
+
190
+ ```ts
191
+ import type { Context } from '@deepseek-ai/cordis'
192
+ import type {} from 'acryl-db'
193
+
194
+ export const name = 'notes'
195
+ export const inject = ['db']
196
+
197
+ export function apply(ctx: Context) {
198
+ void ctx.db.run('INSERT INTO notes (title) VALUES (?)', 'first')
199
+ }
200
+ ```
201
+
202
+ The plugin never imports a driver, so replacing the SQLite row by another provider is a change to the configuration.
@@ -12,8 +12,8 @@ A `json` validation target reads the body only when the request has a JSON `Cont
12
12
 
13
13
  ## Which runtimes are verified?
14
14
 
15
- Node.js. Bun is experimental. The others are listed, as not yet verified, on [Other runtimes](../getting-started/other-runtimes.md).
15
+ Node.js. Bun is experimental. The hosting platforms with an adapter (AWS Lambda, Lambda@Edge, Cloudflare Workers and Pages, Vercel, Netlify, Service Worker) and the ones without are listed on [Other runtimes](../getting-started/other-runtimes.md).
16
16
 
17
17
  ## Where are the parts marked "not yet available"?
18
18
 
19
- Their pages are part of the documentation so that the structure is complete; each starts with a note saying so. They are enabled as they are ported.
19
+ Their pages are part of the documentation so that the structure is complete; each starts with a note saying so. They are enabled as they are built.
@@ -3,31 +3,34 @@
3
3
  This page shows the parts a small multi-user app needs, in the order an app uses them: a database, passwords, a cookie session, HTML forms with server-side validation, and a JSON API that shares the same rules.
4
4
  It is the shape of a "Notes" app built from these docs alone (sign up, log in, create, edit, delete and search notes, a typed test client for the API).
5
5
 
6
- ## A database with `node:sqlite`
7
-
8
- Node.js 22.19 and later include SQLite, so there is nothing to install. `DatabaseSync` is synchronous, which suits a small app.
6
+ ## A database with the `db` port
7
+
8
+ `openDb` from `acrylblends/db/sqlite` opens a SQLite file (Node.js 22.19 and later include SQLite, so there is nothing to install), applies the SQL files in `migrations/`, and returns `{ db, close }`.
9
+ Every call on `db` returns a promise, so `await` it. The [data guide](data.md) has the whole story: migrations, transactions, tests and shutdown.
10
+
11
+ ```sql
12
+ -- migrations/001-notes.sql
13
+ CREATE TABLE users (
14
+ id INTEGER PRIMARY KEY,
15
+ email TEXT NOT NULL UNIQUE,
16
+ password_hash TEXT NOT NULL
17
+ );
18
+
19
+ CREATE TABLE sessions (
20
+ token TEXT PRIMARY KEY,
21
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
22
+ );
23
+ ```
9
24
 
10
25
  ```ts
11
- import { DatabaseSync } from 'node:sqlite'
12
-
13
- export function openDb(path = process.env.NOTES_DB ?? 'notes.db'): DatabaseSync {
14
- const db = new DatabaseSync(path)
15
- db.exec(`
16
- CREATE TABLE IF NOT EXISTS users (
17
- id INTEGER PRIMARY KEY,
18
- email TEXT NOT NULL UNIQUE,
19
- password_hash TEXT NOT NULL
20
- );
21
- CREATE TABLE IF NOT EXISTS sessions (
22
- token TEXT PRIMARY KEY,
23
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
24
- );
25
- `)
26
- return db
26
+ import { openDb } from 'acrylblends/db/sqlite'
27
+
28
+ export function openNotesDb(path = process.env.NOTES_DB ?? 'data/notes.sqlite') {
29
+ return openDb({ path, migrations: 'migrations' })
27
30
  }
28
31
  ```
29
32
 
30
- Take the database as an argument (`createApp(db)`) instead of opening it at import time. A test then passes `openDb(':memory:')`, and importing the app module does not create a file.
33
+ Take the database as an argument (`createApp(db)`) instead of opening it at import time. A test then opens `openNotesDb(':memory:')`, and importing the app module does not create a file.
31
34
 
32
35
  ## Passwords with `node:crypto`
33
36
 
@@ -63,20 +66,21 @@ import { Acryl } from 'acrylblends'
63
66
  import { createMiddleware } from 'acrylblends/factory'
64
67
  import { deleteCookie, getCookie, setCookie } from 'acrylblends/cookie'
65
68
  import { randomBytes } from 'node:crypto'
66
- import type { DatabaseSync } from 'node:sqlite'
69
+ import type { Db } from 'acrylblends/db'
67
70
 
68
71
  type Env = { Variables: { user: { id: number; email: string } } }
69
72
 
70
- export function createApp(db: DatabaseSync) {
73
+ export function createApp(db: Db) {
71
74
  const userFor = (token: string | undefined) =>
72
75
  token
73
- ? (db
74
- .prepare('SELECT users.id AS id, users.email AS email FROM sessions JOIN users ON users.id = sessions.user_id WHERE token = ?')
75
- .get(token) as { id: number; email: string } | undefined)
76
+ ? db.get<{ id: number; email: string }>(
77
+ 'SELECT users.id AS id, users.email AS email FROM sessions JOIN users ON users.id = sessions.user_id WHERE token = ?',
78
+ token
79
+ )
76
80
  : undefined
77
81
 
78
82
  const requireUser = createMiddleware<Env>(async (c, next) => {
79
- const user = userFor(getCookie(c, 'sid'))
83
+ const user = await userFor(getCookie(c, 'sid'))
80
84
  if (!user) return c.redirect('/login')
81
85
  c.set('user', user)
82
86
  await next()
@@ -87,16 +91,16 @@ export function createApp(db: DatabaseSync) {
87
91
  app.use('/notes/*', requireUser)
88
92
  app.get('/notes', (c) => c.text(`Hello ${c.get('user').email}`))
89
93
 
90
- app.post('/login-example', (c) => {
94
+ app.post('/login-example', async (c) => {
91
95
  const token = randomBytes(32).toString('hex')
92
- db.prepare('INSERT INTO sessions (token, user_id) VALUES (?, ?)').run(token, 1)
96
+ await db.run('INSERT INTO sessions (token, user_id) VALUES (?, ?)', token, 1)
93
97
  setCookie(c, 'sid', token, { path: '/', httpOnly: true, sameSite: 'Lax', maxAge: 60 * 60 * 24 * 30 })
94
98
  return c.redirect('/notes', 303)
95
99
  })
96
100
 
97
- app.post('/logout', (c) => {
101
+ app.post('/logout', async (c) => {
98
102
  const token = getCookie(c, 'sid')
99
- if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
103
+ if (token) await db.run('DELETE FROM sessions WHERE token = ?', token)
100
104
  deleteCookie(c, 'sid', { path: '/' })
101
105
  return c.redirect('/login', 303)
102
106
  })
@@ -4,7 +4,7 @@ The ConnInfo Helper helps you to get the connection information. For example, yo
4
4
 
5
5
  ## Import
6
6
 
7
- Only the Node.js adapter, `acrylblends/node-server`, is available today. The adapters for the other platforms (Cloudflare Workers, Bun, Vercel, AWS Lambda, Netlify and Lambda@Edge) are **not yet available**; their imports are shown for completeness and are not checked by the docs tests.
7
+ Each adapter has its own `getConnInfo`: `acrylblends/node-server`, `acrylblends/cloudflare-workers`, `acrylblends/vercel`, `acrylblends/aws-lambda`, `acrylblends/netlify`, `acrylblends/lambda-edge` and `acrylblends/bun`. The Bun example is not type-checked by the docs tests, which run without Bun's types.
8
8
 
9
9
  **Cloudflare Workers**
10
10
 
@@ -98,7 +98,7 @@ export interface FileSystemModule {
98
98
 
99
99
  ### Using the adapter for Bun
100
100
 
101
- On Bun, a `toSSG` function is provided by `acrylblends/bun` (not yet available).
101
+ On Bun, a `toSSG` function is provided by `acrylblends/bun`; it writes the files with `Bun.write`.
102
102
 
103
103
  ### Options
104
104
 
@@ -170,7 +170,8 @@ async function readEvents(body: ReadableStream<Uint8Array>, on: (event: string,
170
170
  for (;;) {
171
171
  const { done, value } = await reader.read()
172
172
  if (done) return
173
- buffer += decoder.decode(value, { stream: true })
173
+ // Line endings may be CRLF; a \r at the end of a chunk is completed by the next chunk, so normalise the whole buffer.
174
+ buffer = (buffer + decoder.decode(value, { stream: true })).replaceAll('\r\n', '\n')
174
175
  const blocks = buffer.split('\n\n')
175
176
  buffer = blocks.pop() ?? ''
176
177
  for (const block of blocks) {
@@ -178,7 +179,7 @@ async function readEvents(body: ReadableStream<Uint8Array>, on: (event: string,
178
179
  const data: string[] = []
179
180
  for (const line of block.split('\n')) {
180
181
  if (line.startsWith('event:')) event = line.slice(6).trim()
181
- else if (line.startsWith('data:')) data.push(line.slice(5).trimStart())
182
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) // the SSE spec strips one leading space only
182
183
  }
183
184
  on(event, data.join('\n'))
184
185
  }
@@ -1,11 +1,11 @@
1
1
  # WebSocket Helper
2
2
 
3
3
  WebSocket Helper is a helper for server-side WebSockets in Acryl applications.
4
- The Node.js adapter is available; the others are not yet available.
4
+ The Node.js, Cloudflare Workers and Bun adapters are available.
5
5
 
6
6
  ## Import
7
7
 
8
- Only the Node.js adapter, `acrylblends/node-server`, is available today. The adapters for the other platforms (Cloudflare Workers, Bun, Vercel, AWS Lambda, Netlify and Lambda@Edge) are **not yet available**; their imports are shown for completeness and are not checked by the docs tests.
8
+ The Node.js adapter (`acrylblends/node-server`), the Cloudflare Workers adapter (`acrylblends/cloudflare-workers`) and the Bun adapter (`acrylblends/bun`) provide `upgradeWebSocket`. The other platforms have no WebSocket support in their adapters. The Bun adapter is checked on Bun in the framework's release check; its examples below are not type-checked by the docs tests, which run without Bun's types.
9
9
 
10
10
  **Cloudflare Workers**
11
11
 
@@ -31,7 +31,7 @@ app.use(
31
31
  app.get('/', (c) => c.text('Acryl!'))
32
32
  ```
33
33
 
34
- Pass the `getConninfo` from the [ConnInfo helper](../../helpers/conninfo.md) appropriate for your environment as the first argument of `ipRestriction`. For a runtime whose adapter is not yet available there is no `getConnInfo` to pass yet.
34
+ Pass the `getConninfo` from the [ConnInfo helper](../../helpers/conninfo.md) appropriate for your environment as the first argument of `ipRestriction`. Every adapter has one (`acrylblends/bun`, `acrylblends/aws-lambda`, `acrylblends/node-server` and the others).
35
35
 
36
36
  ## Rules
37
37
 
@@ -2,8 +2,8 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "docs/framework",
5
- "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
5
+ "commit": "4721bb00960fa4e157e6070c330cf87c148739fc"
6
6
  },
7
- "files": 91,
8
- "digest": "sha256:ea881fe758d9d9add8a8d3837f1dddc6938c3e021cb4ea5f1ce5cfb31af7ef2c"
7
+ "files": 99,
8
+ "digest": "sha256:ce484a4cf2bc9781d188fe468e98c498052d50155bb72f84c0a1ad384b1a5e85"
9
9
  }