acrylblends 0.6.0 → 0.6.2

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 (128) hide show
  1. package/README.md +8 -6
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +13 -1
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/api/presets.md +0 -3
  7. package/docs/framework/cordis/acryl-in-a-cordis-app.md +3 -0
  8. package/docs/framework/getting-started/basic.md +4 -4
  9. package/docs/framework/guides/jsx.md +1 -1
  10. package/docs/framework/guides/middleware.md +1 -1
  11. package/docs/framework/helpers/cookie.md +1 -1
  12. package/docs/framework/helpers/dev.md +0 -3
  13. package/docs/framework/helpers/jwt.md +1 -1
  14. package/docs/framework/helpers/proxy.md +0 -3
  15. package/docs/framework/middleware/builtin/basic-auth.md +6 -6
  16. package/docs/framework/middleware/builtin/cache.md +0 -3
  17. package/docs/framework/middleware/builtin/combine.md +1 -1
  18. package/docs/framework/middleware/builtin/ip-restriction.md +1 -1
  19. package/docs/framework/middleware/builtin/logger.md +1 -1
  20. package/docs/framework/middleware/builtin/mount.md +0 -3
  21. package/docs/framework/upstream.json +2 -2
  22. package/lib/accepts.d.ts +1 -1
  23. package/lib/acryl-base.d.ts +1 -1
  24. package/lib/adapter.d.ts +1 -1
  25. package/lib/{app-CmAYW1Al.d.ts → app-BdrVHBlJ.d.ts} +1 -1
  26. package/lib/basic-auth.d.ts +1 -1
  27. package/lib/bearer-auth.d.ts +1 -1
  28. package/lib/body-limit.d.ts +1 -1
  29. package/lib/cache.d.ts +43 -0
  30. package/lib/cache.js +169 -0
  31. package/lib/client.d.ts +2 -2
  32. package/lib/combine.d.ts +1 -1
  33. package/lib/compress.d.ts +1 -1
  34. package/lib/conninfo.d.ts +2 -2
  35. package/lib/{context-Bm7rI-oK.d.ts → context-B5Y1lLha.d.ts} +1 -1
  36. package/lib/context-storage.d.ts +1 -1
  37. package/lib/{cookie-ClMaKDQf.d.ts → cookie-D9_nQbsM.d.ts} +1 -1
  38. package/lib/cookie.d.ts +3 -3
  39. package/lib/cors.d.ts +1 -1
  40. package/lib/csrf.d.ts +1 -1
  41. package/lib/dev.d.ts +19 -0
  42. package/lib/dev.js +50 -0
  43. package/lib/etag.d.ts +1 -1
  44. package/lib/factory.d.ts +2 -2
  45. package/lib/{index-B-3F4wt2.d.ts → index-DtEoak1i.d.ts} +2 -2
  46. package/lib/{index-UTBmM3GX.d.ts → index-Kqou8Fjh.d.ts} +2 -2
  47. package/lib/{index-oOClm_Xk.d.ts → index-m12zcmph.d.ts} +3 -3
  48. package/lib/index.d.ts +4 -4
  49. package/lib/ip-restriction.d.ts +2 -2
  50. package/lib/jwk.d.ts +4 -4
  51. package/lib/{jwt-Dg493Tai.d.ts → jwt-BA65QhnJ.d.ts} +1 -1
  52. package/lib/jwt.d.ts +4 -4
  53. package/lib/language.d.ts +1 -1
  54. package/lib/linear-router-C3FPib6h.js +96 -0
  55. package/lib/logger.d.ts +1 -1
  56. package/lib/loopback-only.d.ts +1 -1
  57. package/lib/method-not-allowed.d.ts +2 -2
  58. package/lib/method-override.d.ts +2 -2
  59. package/lib/mount.d.ts +44 -0
  60. package/lib/mount.js +67 -0
  61. package/lib/node-server/conninfo.d.ts +1 -1
  62. package/lib/node-server/serve-static.d.ts +1 -1
  63. package/lib/node-server.d.ts +1 -1
  64. package/lib/pattern-router-HZxjDGEQ.js +41 -0
  65. package/lib/powered-by.d.ts +1 -1
  66. package/lib/pretty-json.d.ts +1 -1
  67. package/lib/proxy.d.ts +72 -0
  68. package/lib/proxy.js +121 -0
  69. package/lib/quick.d.ts +8 -0
  70. package/lib/quick.js +17 -0
  71. package/lib/request-id.d.ts +1 -1
  72. package/lib/request.d.ts +1 -1
  73. package/lib/route.d.ts +1 -1
  74. package/lib/router/linear-router.js +1 -95
  75. package/lib/router/pattern-router.js +1 -40
  76. package/lib/schema-validator.d.ts +1 -1
  77. package/lib/secure-headers.d.ts +1 -1
  78. package/lib/serve-static.d.ts +1 -1
  79. package/lib/streaming.d.ts +1 -1
  80. package/lib/testing.d.ts +4 -4
  81. package/lib/timeout.d.ts +1 -1
  82. package/lib/timing.d.ts +1 -1
  83. package/lib/tiny.d.ts +8 -0
  84. package/lib/tiny.js +15 -0
  85. package/lib/trailing-slash.d.ts +1 -1
  86. package/lib/{types-CHesD1KG.d.ts → types-Bd29gh2o.d.ts} +3 -3
  87. package/lib/{types-CwrQjlaM.d.ts → types-D9DEXMjE.d.ts} +3 -1
  88. package/lib/{types-CDO6I3B0.d.ts → types-ZM9gIiKh.d.ts} +1 -1
  89. package/lib/types.d.ts +1 -1
  90. package/lib/utils/body.d.ts +1 -1
  91. package/lib/utils/cookie.d.ts +1 -1
  92. package/lib/utils/crypto.d.ts +1 -1
  93. package/lib/utils/ipaddr.d.ts +1 -1
  94. package/lib/utils/jwt.d.ts +1 -1
  95. package/lib/utils/types.d.ts +2 -2
  96. package/lib/validator.d.ts +2 -2
  97. package/lib/ws.d.ts +1 -1
  98. package/package.json +25 -1
  99. package/plugins/acryl-inspect/upstream.json +1 -1
  100. package/plugins/acryl-node-server/upstream.json +1 -1
  101. package/plugins/acryl-router/src/core/helper/dev/index.test.ts +181 -0
  102. package/plugins/acryl-router/src/core/helper/dev/index.ts +79 -0
  103. package/plugins/acryl-router/src/core/helper/proxy/index.test.ts +356 -0
  104. package/plugins/acryl-router/src/core/helper/proxy/index.ts +203 -0
  105. package/plugins/acryl-router/src/core/middleware/cache/index.test.ts +1211 -0
  106. package/plugins/acryl-router/src/core/middleware/cache/index.ts +323 -0
  107. package/plugins/acryl-router/src/core/middleware/mount/index.test.ts +334 -0
  108. package/plugins/acryl-router/src/core/middleware/mount/index.ts +108 -0
  109. package/plugins/acryl-router/src/core/preset/quick.test.ts +42 -0
  110. package/plugins/acryl-router/src/core/preset/quick.ts +24 -0
  111. package/plugins/acryl-router/src/core/preset/tiny.test.ts +42 -0
  112. package/plugins/acryl-router/src/core/preset/tiny.ts +20 -0
  113. package/plugins/acryl-router/src/core/utils/types.ts +3 -0
  114. package/plugins/acryl-router/src/helpers/index.ts +2 -0
  115. package/plugins/acryl-router/src/middleware/index.ts +2 -0
  116. package/plugins/acryl-router/src/service.ts +22 -5
  117. package/plugins/acryl-router/upstream.json +2 -2
  118. package/plugins/acryl-router-http/upstream.json +1 -1
  119. package/plugins/acryl-state/upstream.json +1 -1
  120. package/plugins/desktop/upstream.json +1 -1
  121. package/plugins/desktop-web/main.ts +92 -0
  122. package/plugins/desktop-web/plugin.json +17 -0
  123. package/plugins/desktop-web/shutdown.ts +177 -0
  124. package/plugins/desktop-web/upstream.json +12 -0
  125. package/plugins/desktop-web/user-data.ts +38 -0
  126. package/plugins/desktop-web/window.ts +29 -0
  127. package/plugins/tsconfig.json +2 -0
  128. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @module
3
+ * Mount Helper for Acryl.
4
+ */
5
+
6
+ import type { Context, ExecutionContext } from '../../context.ts'
7
+ import { routePath } from '../../helper/route/index.ts'
8
+ import type { MiddlewareHandler } from '../../types.ts'
9
+
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ type ApplicationHandler = (request: Request, ...args: any) => Response | Promise<Response>
12
+ type MountOptionHandler = (c: Context) => unknown
13
+ type MountReplaceRequest = (originalRequest: Request) => Request
14
+ type MountOptions =
15
+ | MountOptionHandler
16
+ | {
17
+ optionHandler?: MountOptionHandler
18
+ replaceRequest?: MountReplaceRequest | false
19
+ }
20
+
21
+ const defaultReplaceRequest = (c: Context): Request => {
22
+ // e.g. `/another-app/*` (basePath is already merged) -> `/another-app`
23
+ const pathPrefix = routePath(c).replace(/\/\*$/, '')
24
+ const url = new URL(c.req.raw.url)
25
+ url.pathname = c.req.path.slice(pathPrefix.length) || '/'
26
+ return new Request(url, c.req.raw)
27
+ }
28
+
29
+ const defaultGetOptions = (c: Context): unknown[] => {
30
+ let executionContext: ExecutionContext | undefined = undefined
31
+ try {
32
+ executionContext = c.executionCtx
33
+ } catch {} // Do nothing
34
+ return [c.env, executionContext]
35
+ }
36
+
37
+ /**
38
+ * `mount()` allows you to mount applications built with other frameworks into your Acryl application.
39
+ *
40
+ *
41
+ * @param {Function} applicationHandler - other Request Handler
42
+ * @param {MountOptions} [options] - options of `mount()`
43
+ * @returns {MiddlewareHandler} handler to register with `app.all()`
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { Router as IttyRouter } from 'itty-router'
48
+ * import { Acryl } from 'acrylblends'
49
+ * import { mount } from 'acrylblends/mount'
50
+ * // Create itty-router application
51
+ * const ittyRouter = IttyRouter()
52
+ * // GET /itty-router/hello
53
+ * ittyRouter.get('/hello', () => new Response('Hello from itty-router'))
54
+ *
55
+ * const app = new Acryl()
56
+ * app.all('/itty-router/*', mount(ittyRouter.handle))
57
+ * ```
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const app = new Acryl()
62
+ * // Send the request to another application without modification.
63
+ * app.all('/app/*', mount(anotherApp, {
64
+ * replaceRequest: (req) => req,
65
+ * }))
66
+ * ```
67
+ */
68
+ export const mount = (
69
+ applicationHandler: ApplicationHandler,
70
+ options?: MountOptions
71
+ ): MiddlewareHandler => {
72
+ // handle options
73
+ let replaceRequest: MountReplaceRequest | undefined
74
+ let optionHandler: MountOptionHandler | undefined
75
+ if (options) {
76
+ if (typeof options === 'function') {
77
+ optionHandler = options
78
+ } else {
79
+ optionHandler = options.optionHandler
80
+ if (options.replaceRequest === false) {
81
+ replaceRequest = (request) => request
82
+ } else {
83
+ replaceRequest = options.replaceRequest
84
+ }
85
+ }
86
+ }
87
+
88
+ // prepare handlers for request
89
+ const getOptions: (c: Context) => unknown[] = optionHandler
90
+ ? (c) => {
91
+ const options = optionHandler!(c)
92
+ return Array.isArray(options) ? options : [options]
93
+ }
94
+ : defaultGetOptions
95
+
96
+ return async (c, next) => {
97
+ const request = replaceRequest ? replaceRequest(c.req.raw) : defaultReplaceRequest(c)
98
+ const res = await applicationHandler(request, ...getOptions(c))
99
+
100
+ if (res) {
101
+ return res
102
+ }
103
+
104
+ await next()
105
+ }
106
+ }
107
+
108
+ export type { ApplicationHandler, MountOptionHandler, MountReplaceRequest, MountOptions }
@@ -0,0 +1,42 @@
1
+ import { getRouterName } from '../helper/dev/index.ts'
2
+ import { Acryl } from './quick.ts'
3
+
4
+ describe('acryl-router/quick preset', () => {
5
+ it('Should have SmartRouter + LinearRouter', async () => {
6
+ const app = new Acryl()
7
+ expect(getRouterName(app)).toBe('SmartRouter + LinearRouter')
8
+ })
9
+ })
10
+
11
+ describe('Generics for Bindings and Variables', () => {
12
+ interface CloudflareBindings {
13
+ MY_VARIABLE: string
14
+ }
15
+
16
+ it('Should not throw type errors', () => {
17
+ // @ts-expect-error Bindings should extend object
18
+ new Acryl<{
19
+ Bindings: number
20
+ }>()
21
+
22
+ const appWithInterface = new Acryl<{
23
+ Bindings: CloudflareBindings
24
+ }>()
25
+
26
+ appWithInterface.get('/', (c) => {
27
+ expectTypeOf(c.env.MY_VARIABLE).toMatchTypeOf<string>()
28
+ return c.text('/')
29
+ })
30
+
31
+ const appWithType = new Acryl<{
32
+ Bindings: {
33
+ foo: string
34
+ }
35
+ }>()
36
+
37
+ appWithType.get('/', (c) => {
38
+ expectTypeOf(c.env.foo).toMatchTypeOf<string>()
39
+ return c.text('Hello Acryl!')
40
+ })
41
+ })
42
+ })
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @module
3
+ * The preset that uses `LinearRouter`.
4
+ */
5
+
6
+ import { AcrylBase } from '../app-base.ts'
7
+ import type { AcrylOptions } from '../app-base.ts'
8
+ import { LinearRouter } from '../router/linear-router/index.ts'
9
+ import { SmartRouter } from '../router/smart-router/index.ts'
10
+ import { TrieRouter } from '../router/trie-router/index.ts'
11
+ import type { BlankEnv, BlankSchema, Env, Schema } from '../types.ts'
12
+
13
+ export class Acryl<
14
+ E extends Env = BlankEnv,
15
+ S extends Schema = BlankSchema,
16
+ BasePath extends string = '/',
17
+ > extends AcrylBase<E, S, BasePath> {
18
+ constructor(options: AcrylOptions<E> = {}) {
19
+ super(options)
20
+ this.router = new SmartRouter({
21
+ routers: [new LinearRouter(), new TrieRouter()],
22
+ })
23
+ }
24
+ }
@@ -0,0 +1,42 @@
1
+ import { getRouterName } from '../helper/dev/index.ts'
2
+ import { Acryl } from './tiny.ts'
3
+
4
+ describe('acryl-router/tiny preset', () => {
5
+ it('Should have PatternRouter', async () => {
6
+ const app = new Acryl()
7
+ expect(getRouterName(app)).toBe('PatternRouter')
8
+ })
9
+ })
10
+
11
+ describe('Generics for Bindings and Variables', () => {
12
+ interface CloudflareBindings {
13
+ MY_VARIABLE: string
14
+ }
15
+
16
+ it('Should not throw type errors', () => {
17
+ // @ts-expect-error Bindings should extend object
18
+ new Acryl<{
19
+ Bindings: number
20
+ }>()
21
+
22
+ const appWithInterface = new Acryl<{
23
+ Bindings: CloudflareBindings
24
+ }>()
25
+
26
+ appWithInterface.get('/', (c) => {
27
+ expectTypeOf(c.env.MY_VARIABLE).toMatchTypeOf<string>()
28
+ return c.text('/')
29
+ })
30
+
31
+ const appWithType = new Acryl<{
32
+ Bindings: {
33
+ foo: string
34
+ }
35
+ }>()
36
+
37
+ appWithType.get('/', (c) => {
38
+ expectTypeOf(c.env.foo).toMatchTypeOf<string>()
39
+ return c.text('Hello Acryl!')
40
+ })
41
+ })
42
+ })
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @module
3
+ * The preset that uses `PatternRouter`.
4
+ */
5
+
6
+ import { AcrylBase } from '../app-base.ts'
7
+ import type { AcrylOptions } from '../app-base.ts'
8
+ import { PatternRouter } from '../router/pattern-router/index.ts'
9
+ import type { BlankEnv, BlankSchema, Env, Schema } from '../types.ts'
10
+
11
+ export class Acryl<
12
+ E extends Env = BlankEnv,
13
+ S extends Schema = BlankSchema,
14
+ BasePath extends string = '/',
15
+ > extends AcrylBase<E, S, BasePath> {
16
+ constructor(options: AcrylOptions<E> = {}) {
17
+ super(options)
18
+ this.router = new PatternRouter()
19
+ }
20
+ }
@@ -125,6 +125,9 @@ export type BufferSource = ArrayBufferView<ArrayBuffer> | ArrayBuffer
125
125
  /** A Web Crypto key, named from the runtime's own `crypto` so the public types do not need the DOM library. */
126
126
  export type CryptoKey = Awaited<ReturnType<typeof crypto.subtle.importKey>>
127
127
 
128
+ /** What the Web `Headers` constructor accepts, named from the runtime's own `Headers` so the public types do not need the DOM library. */
129
+ export type HeadersInit = NonNullable<ConstructorParameters<typeof Headers>[0]>
130
+
128
131
  /** How a WebSocket hands binary data to `onmessage`; declared here so the public types do not need the DOM library. */
129
132
  export type BinaryType = 'arraybuffer' | 'blob'
130
133
 
@@ -5,6 +5,8 @@ export { createFactory, createMiddleware, Factory } from '../core/helper/factory
5
5
  export { deleteCookie, generateCookie, generateSignedCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from '../core/helper/cookie/index.ts'
6
6
  export { getExtension, getMimeType, mimes, type BaseMime } from '../core/utils/mime.ts'
7
7
  export { html, raw } from '../core/helper/html/index.ts'
8
+ export { proxy } from '../core/helper/proxy/index.ts'
9
+ export { getRouterName, inspectRoutes, showRoutes } from '../core/helper/dev/index.ts'
8
10
  export { matchedRoutes, routePath, baseRoutePath, basePath } from '../core/helper/route/index.ts'
9
11
  export { testClient } from '../core/helper/testing/index.ts'
10
12
  export { stream, streamSSE, streamText, SSEStreamingApi, type SSEMessage } from '../core/helper/streaming/index.ts'
@@ -1,6 +1,7 @@
1
1
  export { basicAuth } from '../core/middleware/basic-auth/index.ts'
2
2
  export { bearerAuth } from '../core/middleware/bearer-auth/index.ts'
3
3
  export { bodyLimit } from '../core/middleware/body-limit/index.ts'
4
+ export { cache } from '../core/middleware/cache/index.ts'
4
5
  export { every, except, some } from '../core/middleware/combine/index.ts'
5
6
  export { compress } from '../core/middleware/compress/index.ts'
6
7
  export { contextStorage, getContext, tryGetContext } from '../core/middleware/context-storage/index.ts'
@@ -14,6 +15,7 @@ export { detectFromCookie, detectFromHeader, detectFromPath, detectFromQuery, la
14
15
  export { logger } from '../core/middleware/logger/index.ts'
15
16
  export { methodNotAllowed } from '../core/middleware/method-not-allowed/index.ts'
16
17
  export { methodOverride } from '../core/middleware/method-override/index.ts'
18
+ export { mount } from '../core/middleware/mount/index.ts'
17
19
  export { poweredBy } from '../core/middleware/powered-by/index.ts'
18
20
  export { prettyJSON } from '../core/middleware/pretty-json/index.ts'
19
21
  export { requestId, type RequestIdVariables } from '../core/middleware/request-id/index.ts'
@@ -54,13 +54,30 @@ declare module '@deepseek-ai/cordis' {
54
54
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
55
55
  export const name = 'acryl-router'
56
56
 
57
- // Any `Acryl`, whatever its Env and schema; the router only reads its routes.
58
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
- export type MountableApp = Acryl<any, any, any>
57
+ /**
58
+ * What {@link Router.route} takes: any app with routes, read structurally. An `Acryl` from this package qualifies, and so does one built from
59
+ * another copy of the framework (the published `acrylblends`), whose classes TypeScript would refuse nominally for their private members:
60
+ * mounting copies the app's routes (and keeps its own `onError`), which is all the router reads. Their handlers are then called with this
61
+ * router's `Context`, which has the same shape.
62
+ */
63
+ export interface MountableApp {
64
+ readonly routes: readonly MountableRoute[]
65
+ }
66
+
67
+ /** One route of a {@link MountableApp}, as the core lists it. A handler of any context type fits (`never` parameters accept every function). */
68
+ export interface MountableRoute {
69
+ readonly basePath: string
70
+ readonly path: string
71
+ readonly method: string
72
+ readonly handler: (c: never, next: never) => unknown
73
+ }
74
+
75
+ /** The core's `app.route` with a structural app: at run time it reads only the routes and the app's own error handler. */
76
+ const copyRoutes = (app: MountableApp): Acryl => new Acryl().route('/', app as unknown as Acryl)
60
77
 
61
78
  interface Mount {
62
79
  readonly path: string
63
- readonly app: MountableApp
80
+ readonly app: Acryl
64
81
  }
65
82
 
66
83
  interface RouterState {
@@ -161,7 +178,7 @@ export class Router extends Service {
161
178
  * mount's code a request is in. The core's `app.route` runs first so a sub-app's own `onError` still applies to its routes.
162
179
  */
163
180
  private snapshot(path: string, app: MountableApp): Mount {
164
- const copy = new Acryl().route('/', app)
181
+ const copy = copyRoutes(app)
165
182
  const tagged = new Acryl()
166
183
  const mount: Mount = { path, app: tagged }
167
184
  for (const route of copy.routes) {
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router",
5
- "commit": "5743482e4199ded676a77f594bce15fd4aa0ca2f"
5
+ "commit": "0174cb01625bfd32d0162ad56179c582f353321e"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -10,7 +10,7 @@
10
10
  "LICENSE"
11
11
  ],
12
12
  "rewrittenSpecifiers": 4,
13
- "digest": "sha256:543a5ca99518eea1fe2e319b7708d62af34a6c24ab3daeb17dde48d67d319d6a",
13
+ "digest": "sha256:e732a94e1cf98c4390780156359603dc5d3d33597baf21457082fb33c120cb90",
14
14
  "testsNotRun": {
15
15
  "tests/loader.spec.ts": "loads the built lib/ entry through the real Loader; the library ships source and has no build"
16
16
  },
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router-http",
5
- "commit": "5743482e4199ded676a77f594bce15fd4aa0ca2f"
5
+ "commit": "0174cb01625bfd32d0162ad56179c582f353321e"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-state",
5
- "commit": "5743482e4199ded676a77f594bce15fd4aa0ca2f"
5
+ "commit": "0174cb01625bfd32d0162ad56179c582f353321e"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "5743482e4199ded676a77f594bce15fd4aa0ca2f"
8
+ "commit": "0174cb01625bfd32d0162ad56179c582f353321e"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The desktop shell of a plain Acryl app: `npm run desktop` serves the app (`src/app.ts`, `export default app`) on a free loopback port from this Electron main process and
3
+ * opens one window on it. Nothing in the app imports electron; every Electron call is in this file and `window.ts`, so a different shell is a change here only.
4
+ *
5
+ * Order matters: the user-data folder is set before anything else (and before the single-instance lock), so one app never shares window state, logs or the lock with
6
+ * another app or with the real user's data.
7
+ */
8
+
9
+ import { mkdirSync, readFileSync } from 'node:fs'
10
+ import type { AddressInfo } from 'node:net'
11
+ import { join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { serve } from 'acrylblends/node-server'
14
+ import { app, BrowserWindow, shell } from 'electron'
15
+ import acrylApp from '../../app.ts'
16
+ import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
17
+ import { resolveDesktopUserDataOverride } from './user-data.ts'
18
+ import { windowOptions } from './window.ts'
19
+
20
+ const projectRoot = fileURLToPath(new URL('../../..', import.meta.url))
21
+ const manifest = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')) as {
22
+ name?: string
23
+ acrylblends?: { desktopPath?: string }
24
+ }
25
+
26
+ /** Electron's user-data folder: an explicit DSH_DESKTOP_USER_DATA, else a folder inside the app's own folder. */
27
+ const userData =
28
+ resolveDesktopUserDataOverride(process.env) ?? join(projectRoot, '.acrylblends', 'electron')
29
+ mkdirSync(userData, { recursive: true, mode: 0o700 })
30
+ app.setPath('userData', userData)
31
+ app.setName(manifest.name ?? 'acryl-app')
32
+
33
+ if (!app.requestSingleInstanceLock()) {
34
+ // The same app is already open: its window takes focus (see `second-instance` below) and this process ends.
35
+ app.quit()
36
+ } else {
37
+ let server: ReturnType<typeof serve> | undefined
38
+ let window: BrowserWindow | undefined
39
+
40
+ const shutdown = createDesktopShutdown(
41
+ () =>
42
+ new Promise<void>((done) => {
43
+ if (server === undefined) return done()
44
+ server.close(() => {
45
+ done()
46
+ })
47
+ // Open streams and keep-alive connections would hold close() open: end them.
48
+ ;(server as { closeAllConnections?: () => void }).closeAllConnections?.()
49
+ }),
50
+ (code) => {
51
+ app.exit(code)
52
+ },
53
+ )
54
+ installShutdownRequests(process, app, (code) => {
55
+ void shutdown.request(code)
56
+ })
57
+
58
+ app.on('second-instance', () => {
59
+ if (window === undefined) return
60
+ if (window.isMinimized()) window.restore()
61
+ window.focus()
62
+ })
63
+ // The window is the app: closing it ends the process on every platform, after the server has stopped.
64
+ app.on('window-all-closed', () => {
65
+ void shutdown.request(0)
66
+ })
67
+
68
+ void app.whenReady().then(async () => {
69
+ const port = await new Promise<number>((resolve) => {
70
+ server = serve(
71
+ { fetch: acrylApp.fetch, port: 0, hostname: '127.0.0.1' },
72
+ (info: AddressInfo) => {
73
+ resolve(info.port)
74
+ },
75
+ )
76
+ })
77
+ const origin = `http://127.0.0.1:${port}`
78
+ window = new BrowserWindow(windowOptions({ title: manifest.name ?? 'acryl-app' }))
79
+ window.once('ready-to-show', () => window?.show())
80
+ // Navigation stays on the app's own server; any other address opens in the system browser.
81
+ window.webContents.setWindowOpenHandler(({ url }) => {
82
+ void shell.openExternal(url)
83
+ return { action: 'deny' }
84
+ })
85
+ window.webContents.on('will-navigate', (event, url) => {
86
+ if (new URL(url).origin === origin) return
87
+ event.preventDefault()
88
+ void shell.openExternal(url)
89
+ })
90
+ await window.loadURL(new URL(manifest.acrylblends?.desktopPath ?? '/', origin).href)
91
+ })
92
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "desktop-web",
3
+ "description": "Run a plain Acryl app (`export default app`) as a desktop app: an Electron window on the app's own server. `npm run desktop`; the app's code never imports electron. `acrylblends add desktop` picks this one for an app without the Blend plugins.",
4
+ "requires": [],
5
+ "inject": [],
6
+ "provides": [],
7
+ "dependencies": {},
8
+ "devDependencies": {
9
+ "electron": "44.0.0"
10
+ },
11
+ "scripts": {
12
+ "desktop": "electron src/plugins/desktop-web/main.ts"
13
+ },
14
+ "gitignore": [".acrylblends/"],
15
+ "omit": ["**/*.test.*"],
16
+ "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in src/plugins/desktop-web)"
17
+ }
@@ -0,0 +1,177 @@
1
+ /** Application-level quit sources that must remain active before any window mounts. */
2
+
3
+ /** Maximum grace allowed for the Cordis tree to dispose before native exit. */
4
+ export const DESKTOP_SHUTDOWN_TIMEOUT_MS = 5_000
5
+
6
+ /**
7
+ * Exit code a dev-mode relaunch uses instead of `app.relaunch()`.
8
+ *
9
+ * `acryl-desktop/scripts/launch-dev.mjs` stages the Electron runtime into a
10
+ * fresh, per-run temp directory and deletes it as soon as this process
11
+ * exits. `app.relaunch()` would spawn a new process pointed at that same
12
+ * (about to be deleted) executable path, which crashes at dyld load time
13
+ * ("Library not loaded: Electron Framework") instead of restarting the app.
14
+ * `main.ts` exits with this code under the dev launcher instead of calling
15
+ * `app.relaunch()`; `launch-dev.mjs` recognizes it and stages a fresh bundle
16
+ * before spawning again. The literal `43` is mirrored in `launch-dev.mjs` -
17
+ * that plain script cannot import this compiled module.
18
+ */
19
+ export const DESKTOP_DEV_RESTART_EXIT_CODE = 43
20
+
21
+ /** Bounded, escalating shutdown controller for the Electron application. */
22
+ export interface DesktopShutdown {
23
+ /** Start graceful disposal, or force exit when a shutdown already owns it. */
24
+ request(code: number): Promise<void>
25
+ }
26
+
27
+ /** Native operations performed after the Host tree has disposed. */
28
+ export interface DesktopNativeExit {
29
+ /** Mark the window close path as a final process exit. */
30
+ prepareToQuit(): void
31
+ /**
32
+ * Schedule a fresh Electron process and terminate the current one - fully
33
+ * owns process exit; `finish()` never calls `exit()` afterward. A packaged
34
+ * install's implementation must call both `app.relaunch()` (schedule) and
35
+ * `app.exit()` (terminate this instance) itself - `app.relaunch()` alone
36
+ * does not end the current process. Calling `exit()` again afterward with
37
+ * a different code is not a graceful no-op: two `app.exit()` calls in
38
+ * quick succession race, and the second one's code wins - confirmed live
39
+ * (`app.exit(DESKTOP_DEV_RESTART_EXIT_CODE)` from the dev-mode branch,
40
+ * immediately followed by `finish()`'s own unconditional `app.exit(0)`,
41
+ * silently replaced the dev-restart signal with a plain exit, so
42
+ * `launch-dev.mjs`'s loop saw code 0, concluded "not a restart," and
43
+ * returned instead of staging a fresh bundle and respawning - the app
44
+ * closed with no automatic restart, every time).
45
+ */
46
+ relaunch(): void
47
+ /** End the current Electron process without another quit event. */
48
+ exit(code: number): void
49
+ }
50
+
51
+ /** Final-exit state shared by ordinary quits and mode-change relaunches. */
52
+ export interface DesktopExitCoordinator {
53
+ /** Mark the next successful exit as a relaunch. */
54
+ requestRelaunch(): void
55
+ /** Complete one native exit after Cordis teardown. */
56
+ finish(code: number): void
57
+ }
58
+
59
+ /**
60
+ * Coordinate the final Electron action without relaunching failed generations.
61
+ * @param native - native application and runtime exit operations.
62
+ * @param beforeExit - listener cleanup that must precede app.exit.
63
+ * @returns a final-exit controller consumed by the shutdown path.
64
+ */
65
+ export function createDesktopExitCoordinator(
66
+ native: DesktopNativeExit,
67
+ beforeExit: () => void,
68
+ ): DesktopExitCoordinator {
69
+ let relaunchRequested = false
70
+ return {
71
+ requestRelaunch() {
72
+ relaunchRequested = true
73
+ },
74
+ finish(code) {
75
+ beforeExit()
76
+ native.prepareToQuit()
77
+ // relaunch() fully owns process termination (see its doc comment) -
78
+ // calling exit() afterward would race it and can silently replace
79
+ // whichever exit code relaunch() actually needs with this one.
80
+ if (relaunchRequested && code === 0) {
81
+ native.relaunch()
82
+ return
83
+ }
84
+ native.exit(code)
85
+ },
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Create one bounded shutdown around the Host Cordis disposer.
91
+ * @param dispose - whole Host tree teardown.
92
+ * @param exit - final native process exit.
93
+ * @param timeoutMs - grace before forced exit.
94
+ * @returns a controller whose second request escalates immediately.
95
+ */
96
+ export function createDesktopShutdown(
97
+ dispose: () => Promise<void>,
98
+ exit: (code: number) => void,
99
+ timeoutMs = DESKTOP_SHUTDOWN_TIMEOUT_MS,
100
+ ): DesktopShutdown {
101
+ let pending: Promise<void> | undefined
102
+ let timeout: ReturnType<typeof setTimeout> | undefined
103
+ let exited = false
104
+
105
+ const exitOnce = (code: number): void => {
106
+ if (exited) return
107
+ exited = true
108
+ if (timeout !== undefined) clearTimeout(timeout)
109
+ exit(code)
110
+ }
111
+
112
+ return {
113
+ request(code) {
114
+ if (pending !== undefined) {
115
+ exitOnce(code)
116
+ return pending
117
+ }
118
+ const failureCode = code === 0 ? 1 : code
119
+ timeout = setTimeout(() => { exitOnce(failureCode) }, timeoutMs)
120
+ pending = Promise.resolve().then(dispose).then(
121
+ () => { exitOnce(code) },
122
+ () => { exitOnce(failureCode) },
123
+ )
124
+ return pending
125
+ },
126
+ }
127
+ }
128
+
129
+ /** Minimal native quit event consumed by the launcher. */
130
+ export interface DesktopQuitEvent {
131
+ /** Cancel Electron's immediate default exit. */
132
+ preventDefault(): void
133
+ }
134
+
135
+ /** Process signal surface used by the shutdown coordinator. */
136
+ export interface DesktopSignalSource {
137
+ /** Register one shutdown signal listener. */
138
+ on(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown
139
+ /** Remove one shutdown signal listener. */
140
+ off(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown
141
+ }
142
+
143
+ /** Electron application surface used by the shutdown coordinator. */
144
+ export interface DesktopQuitSource {
145
+ /** Register the native quit guard. */
146
+ on(event: 'before-quit', listener: (event: DesktopQuitEvent) => void): unknown
147
+ /** Remove the native quit guard. */
148
+ off(event: 'before-quit', listener: (event: DesktopQuitEvent) => void): unknown
149
+ }
150
+
151
+ /**
152
+ * Route every application-level quit source through Cordis teardown.
153
+ * @param signals - process signal owner.
154
+ * @param nativeApp - Electron application event owner.
155
+ * @param requestQuit - idempotent launcher shutdown request.
156
+ * @returns a disposer removing every listener.
157
+ */
158
+ export function installShutdownRequests(
159
+ signals: DesktopSignalSource,
160
+ nativeApp: DesktopQuitSource,
161
+ requestQuit: (code: number) => void,
162
+ ): () => void {
163
+ const interrupt = (): void => { requestQuit(130) }
164
+ const terminate = (): void => { requestQuit(0) }
165
+ const beforeQuit = (event: DesktopQuitEvent): void => {
166
+ event.preventDefault()
167
+ requestQuit(0)
168
+ }
169
+ signals.on('SIGINT', interrupt)
170
+ signals.on('SIGTERM', terminate)
171
+ nativeApp.on('before-quit', beforeQuit)
172
+ return () => {
173
+ signals.off('SIGINT', interrupt)
174
+ signals.off('SIGTERM', terminate)
175
+ nativeApp.off('before-quit', beforeQuit)
176
+ }
177
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "files": {
5
+ "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
+ "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
+ },
8
+ "commit": "0174cb01625bfd32d0162ad56179c582f353321e"
9
+ },
10
+ "rewrittenSpecifiers": 0,
11
+ "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
12
+ }