@weft-js/start 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @weft-js/start
2
2
 
3
- > **Early release (0.1.0).** APIs will change between minor versions.
3
+ > **Early release (0.1.1).** APIs will change between minor versions.
4
4
 
5
5
  The full-stack part of weft: routes from files, server functions, server rendering with streaming, a database, auth and
6
6
  realtime. It runs on Node, Bun or Deno, and doesn't need Rust. An app made with `npm create @weft-js` already has it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weft-js/start",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "weft Start: file routes, server functions, streaming SSR, a database and auth",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "drizzle-zod": "^0.8.0",
21
21
  "mysql2": "^3.24.0",
22
22
  "postgres": ">=3",
23
- "@weft-js/core": "^0.1.0",
23
+ "@weft-js/core": "^0.1.1",
24
24
  "zod": "^4.0.0"
25
25
  },
26
26
  "exports": {
package/src/app.mjs CHANGED
@@ -8,6 +8,7 @@ import { createBuilder, createServer } from 'vite'
8
8
  import { nitro } from 'nitro/vite'
9
9
  import weft from '@weft-js/core/vite'
10
10
  import { devRunner, devSockets } from './dev-runtime.mjs'
11
+ import { holdsSockets } from './live-target.mjs'
11
12
  import weftStart, { startDir } from './vite.mjs'
12
13
 
13
14
  /** An app with a `channel` or `sharedState`: only those pay for crossws in the server bundle (research 32 §1). */
@@ -27,6 +28,8 @@ function config({ root, site, hydration, preset, port, logLevel, command, nitro:
27
28
  configFile: user ?? false,
28
29
  logLevel,
29
30
  server: { port: port ?? 5173, strictPort: port !== undefined },
31
+ // A function host can't hold a socket: the page goes straight to SSE, and a channel says `unavailable` (R024)
32
+ ...(command === 'build' && { define: { __WEFT_SOCKETS__: JSON.stringify(holdsSockets(preset)) } }),
30
33
  plugins: [
31
34
  weft({ hydratable: true }),
32
35
  weftStart({ root, site, hydration }),
@@ -52,10 +55,17 @@ function config({ root, site, hydration, preset, port, logLevel, command, nitro:
52
55
  }
53
56
  }
54
57
 
58
+ /** Where the last build's Nitro wrote, known once it prerenders: a preset may move it (netlify serves `dist`). */
59
+ let wrote
60
+
55
61
  function immutableCards() {
56
62
  let built
57
63
  return {
58
- 'prerender:generate'(_route, instance) { built = instance },
64
+ 'prerender:generate'(_route, instance) {
65
+ built = instance
66
+ const { dir, publicDir } = instance.options.output
67
+ wrote = publicDir.startsWith(dir) ? dir : publicDir
68
+ },
59
69
  'prerender:done'({ prerenderedRoutes }) {
60
70
  if (!built) return
61
71
  for (const r of prerenderedRoutes) {
@@ -73,10 +83,11 @@ export async function dev(options) {
73
83
  return server
74
84
  }
75
85
 
76
- /** Build client and server into `.output/` for `preset` (node by default; bun, deno_server…). */
86
+ /** Build client and server for `preset` (node by default; bun, deno_server…), into `.output/` unless the preset moves it. */
77
87
  export async function build(options) {
78
88
  process.env.WEFT_START_DIR ??= startDir // for the share cards a build draws (og/native.mjs)
79
89
  const builder = await createBuilder(config({ ...options, command: 'build' }))
90
+ wrote = undefined
80
91
  await builder.buildApp()
81
- return { output: join(resolve(options.root), '.output') }
92
+ return { output: wrote ?? join(resolve(options.root), '.output') }
82
93
  }
package/src/channel.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // `runtime.channels`, so nothing of the server ships here.
5
5
 
6
6
  import { Box, DEV, batch, collect as boxes, createRoot, derived, effect, getOwner, onCleanup, state, untrack, type Derived, type State } from '@weft-js/core'
7
- import { HANDOVER, openLine, socketDown } from './link'
7
+ import { HANDOVER, SOCKETS, openLine, socketDown } from './link'
8
8
  import { currentScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track, type Context, type Fn, type Skip, type StandardSchemaV1 } from './data'
9
9
 
10
10
  // ---- definitions ----------------------------------------------------------------------------------------------------
@@ -15,8 +15,11 @@ type Schemas = Record<string, StandardSchemaV1>
15
15
  type Inputs<T extends Schemas> = { [K in keyof T]: In<T[K]> }
16
16
  type Outputs<T extends Schemas> = { [K in keyof T]: Out<T[K]> }
17
17
 
18
- /** Where a connection stands; `unauthorized` waits for `rejoin`. */
19
- export type ChannelStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized'
18
+ /**
19
+ * Where a connection stands; `unauthorized` waits for `rejoin`. `unavailable`: this deploy can't hold a socket (a
20
+ * function host, R024), so nothing connects and nothing is retried.
21
+ */
22
+ export type ChannelStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized' | 'unavailable'
20
23
 
21
24
  /** `room.publish`: who a message goes to, and whether it can be replayed. */
22
25
  export interface PublishOptions {
@@ -363,6 +366,7 @@ const acks = new Map<number, { ok: () => void; no: (e: unknown) => void; n: numb
363
366
  let ackId = 0
364
367
 
365
368
  async function post(j: Join, name: string, message: unknown): Promise<void> {
369
+ if (!SOCKETS && !(DEV && net())) throw new FnError({ code: 'R024', message: "this deploy can't hold a socket: not sent" }, 503)
366
370
  if (!j.joined && !j.refused) {
367
371
  await new Promise<void>(ok => {
368
372
  const done = () => {
@@ -444,6 +448,8 @@ function rejoin(j: Join) {
444
448
 
445
449
  function start() {
446
450
  if (running) return
451
+ // a function host: no socket to dial, so say so once instead of retrying forever
452
+ if (!SOCKETS && !(DEV && net())) return statusAll('unavailable')
447
453
  running = true
448
454
  queueMicrotask(() => void run())
449
455
  }
package/src/contract.mjs CHANGED
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
7
7
  import { join, resolve } from 'node:path'
8
8
  import { createServer } from 'vite'
9
9
  import weft from '@weft-js/core/vite'
10
- import { literalSecrets, livePlan, refusal, resolveTarget, scanRequired } from './live-target.mjs'
10
+ import { holdsSockets, literalSecrets, liveTarget, livePlan, refusal, resolveTarget, scanRequired } from './live-target.mjs'
11
11
  import { readMigrations } from './migrations.mjs'
12
12
  import { shapeTree, toJson } from './shape.mjs'
13
13
  import weftStart, { startDir } from './vite.mjs'
@@ -151,7 +151,7 @@ const MULTI = /^(vercel|netlify|aws[-_]|azure|cloudflare|firebase|deno[-_]deploy
151
151
  const usesLive = root => {
152
152
  const walk = dir => readdirSync(dir, { withFileTypes: true }).some(d => {
153
153
  const f = join(dir, d.name)
154
- return d.isDirectory() ? walk(f) : /\.[cm]?[jt]sx?$/.test(d.name) && /\.live\(/.test(readFileSync(f, 'utf8'))
154
+ return d.isDirectory() ? walk(f) : /\.[cm]?[jt]sx?$/.test(d.name) && /[\w)\]]\.live\(/.test(readFileSync(f, 'utf8'))
155
155
  })
156
156
  return existsSync(join(root, 'src')) && walk(join(root, 'src'))
157
157
  }
@@ -287,7 +287,11 @@ export function writeContract({ root, site = {}, preset, rust, live, verify }) {
287
287
  return withFns({ root, site }, async (fns, server) => {
288
288
  const { diagnostics, ...contract } = describe(fns, rust)
289
289
  const mod = await server.ssrLoadModule('virtual:weft/realtime')
290
- const bus = busProblem({ preset, bus: mod.default?.bus?.name ?? 'memory', streams: [...fns.values()].some(f => f.kind === 'stream' || f.kind === 'channel' || f.kind === 'shared'), root })
290
+ const socket = [...fns.values()].find(f => f.kind === 'channel' || f.kind === 'shared')
291
+ // a channel needs the socket itself: on a function host no bus helps, so R024 says where it can run instead of R002
292
+ const unheld = socket && !holdsSockets(preset)
293
+ if (unheld) diagnostics.push({ code: 'R024', severity: 'error', why: `\`${socket.$id.replace(/\..*$/, '')}\` is a ${socket.kind === 'shared' ? 'shared state' : 'channel'}, which needs a WebSocket, and ${liveTarget(preset).host} on \`${preset}\` can't hold one: the page loads, and the channel says \`unavailable\`. \`weft deploy hosts\` lists the hosts that can (Fly, Render).` })
294
+ const bus = busProblem({ preset, bus: mod.default?.bus?.name ?? 'memory', streams: [...fns.values()].some(f => f.kind === 'stream' || (!unheld && (f.kind === 'channel' || f.kind === 'shared'))), root })
291
295
  if (bus) diagnostics.push({ code: 'R002', severity: 'error', why: `${bus}. Add \`@weft-js/realtime-redis\` or \`@weft-js/realtime-nats\` to \`plugins\`, or set \`postgresBus\` in \`src/realtime.server.ts\`.` })
292
296
  diagnostics.push(...planOf({ root, preset, realtime: mod.default }).diagnostics)
293
297
  let verified = []
package/src/link.ts CHANGED
@@ -145,10 +145,15 @@ interface Mine { id: string; hello: string; frame(text: string): void; closed(co
145
145
  const lines = new Map<string, Mine>()
146
146
  let nth = 0
147
147
 
148
+ declare const __WEFT_SOCKETS__: boolean | undefined
149
+ /** Whether this deploy can hold a socket. `weft build` says no for a function host (Netlify, Vercel, Lambda…). */
150
+ export const SOCKETS = typeof __WEFT_SOCKETS__ === 'undefined' || __WEFT_SOCKETS__
151
+
148
152
  /** Sockets that never said hello in a row: the page's way to learn this host has none. */
149
153
  export let socketFailures = 0
150
- /** The one who asked goes round SSE once, and the socket gets another chance after. */
154
+ /** The one who asked goes round SSE once, and the socket gets another chance after. Where the build said none, always. */
151
155
  export const noSockets = () => {
156
+ if (!SOCKETS) return true
152
157
  const none = socketFailures >= 2
153
158
  if (none) socketFailures = 0
154
159
  return none
@@ -351,7 +356,7 @@ function forward(owner: string, text: string) {
351
356
  }
352
357
 
353
358
  function wantSocket() {
354
- if (ws || retry !== undefined || !election?.leading() || offline()) return
359
+ if (!SOCKETS || ws || retry !== undefined || !election?.leading() || offline()) return
355
360
  if (!attempt) return dial()
356
361
  retry = setTimeout(() => {
357
362
  retry = undefined
@@ -23,6 +23,9 @@ export function liveTarget(preset = 'node') {
23
23
  return hit ? { preset, held: false, host: hit[1] } : { preset, held: true, host: 'a long-running server' }
24
24
  }
25
25
 
26
+ /** Whether a deploy can hold a browser's socket: a long-running server, a Durable Object, or Deno's upgraded request. */
27
+ export const holdsSockets = (preset = 'node') => liveTarget(preset).held || /^(cloudflare[-_]durable|deno[-_]deploy)/.test(preset)
28
+
26
29
  const SKIP = new Set(['node_modules', '.weft', '.output', 'dist'])
27
30
 
28
31
  /** `live: { required: true }` on a query, and `.live(input, { required: true })` in a component, with where. */
package/src/run.tsx CHANGED
@@ -62,6 +62,10 @@ export async function start(routes: ClientRoutes, options: { container?: Element
62
62
  let warm: { path: string; at: number; res: Promise<Response> } | undefined
63
63
  // A fade pictures the whole page twice, and the picture holds up every frame. Where that's slow, the page just changes.
64
64
  let fades = !(navigator.hardwareConcurrency <= 4)
65
+ // A navigation before the first frame (a moved page's onMount) has no old picture to hold: Chrome aborts that
66
+ // transition and logs an InvalidStateError. Two frames in, the page has painted.
67
+ let painted = false
68
+ requestAnimationFrame(() => requestAnimationFrame(() => (painted = true)))
65
69
  async function go(href: string, opts: NavigateOptions & { pop?: boolean; hops?: number } = {}): Promise<void> {
66
70
  const url = new URL(href, location.href)
67
71
  if (url.origin !== location.origin) return location.assign(url.href)
@@ -100,7 +104,7 @@ export async function start(routes: ClientRoutes, options: { container?: Element
100
104
  }
101
105
  }
102
106
  // The old page stays on screen, as a picture, until the new one is ready to fade in. `hidden`: nothing would show it.
103
- if (fades && document.startViewTransition && !document.hidden && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
107
+ if (fades && painted && document.startViewTransition && !document.hidden && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
104
108
  const started = performance.now()
105
109
  const fade = document.startViewTransition(swap)
106
110
  void fade.ready.then(() => { if (performance.now() - started > SLOW_FADE) fades = false }, () => {})
package/src/server.tsx CHANGED
@@ -147,6 +147,13 @@ const CARD = { width: 1200, height: 630 }
147
147
  const stores = () => ({ createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, batch: (f: () => void) => f() })
148
148
  const json = (v: unknown) => JSON.stringify(v ?? null).replace(/</g, '\\u003c')
149
149
  const esc = (s: string) => s.replace(/[&<"]/g, c => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&quot;'))
150
+ /**
151
+ * A redirect's body. A server's 3xx is followed by its `location`; a prerendered one is a file on a static host, and this
152
+ * is what that file says: go now, and the address to index. No stylesheet, so no view transition starts on a page
153
+ * that never paints (Chrome aborts it, and logs an InvalidStateError).
154
+ */
155
+ const moved = (href: string) =>
156
+ `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Moved</title><meta name="robots" content="noindex"><link rel="canonical" href="${esc(href)}"><meta http-equiv="refresh" content="0; url=${esc(href)}"><a href="${esc(href)}">Moved here</a>`
150
157
  const thenable = (v: unknown): v is PromiseLike<unknown> => typeof (v as Partial<PromiseLike<unknown>> | null | undefined)?.then === 'function'
151
158
 
152
159
  /** The origin a head's URLs are built on, or none: then canonical and og:url stay out. */
@@ -365,7 +372,7 @@ export function createHandler(options: HandlerOptions) {
365
372
  async function page(url: URL, request: Request, scope: Scope): Promise<Response> {
366
373
  const path = url.pathname + url.search
367
374
  const r = await resolve(path, request)
368
- if (r.redirect) return withHeaders(new Response(null, { status: r.redirect.status, headers: { location: r.redirect.href, 'cache-control': 'no-cache' } }), scope)
375
+ if (r.redirect) return withHeaders(new Response(moved(r.redirect.href), { status: r.redirect.status, headers: { location: r.redirect.href, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' } }), scope)
369
376
  scope.started = true // from here a redirect can't change the status (Q007)
370
377
  // The server renders from the raw data (deferred fields are still promises, for Boundaries to stream); the page
371
378
  // carries the settled part.